Skip to content

Control Policy

Prakhar Yadav edited this page Sep 16, 2026 · 1 revision

Control Policy

Pure control policy (src/policy.rs): no I/O, no clock, no threads. One Controller state machine consumed by the supervisor. All temperatures are MilliC(pub i32) (milli-°C, exactly as sysfs provides) until display; rpm are bare u32. Table-tested against the debate's traces (see Development and Testing).

Constants (all pub const in policy.rs)

Constant Value Consumed by Purpose
SLEW_MAX_RPM_PER_POLL 750 policy Max rpm change per poll toward target
SENSOR_LOSS_POLLS 3 policy Consecutive all-sensors-failing polls → ReturnToAuto
OVERSHOOT_POLLS 3 policy Consecutive polls with t_eff ≥ max − 1 → bypass slew to max
VERIFY_TOLERANCE_RPM 150 supervisor L1 |actual − last_written| > 150 triggers re-assert
WRITE_FAIL_FALLBACK 3 supervisor L1 Failed re-asserts → AUTO + monitor-only
WRITE_ECHO_TOLERANCE_RPM 50 smc verify fan1_output echo read-back tolerance
ECHO_SETTLE_MS 1500 smc verify Echo settle window (10 × 150 ms samples)
ECHO_SETTLE_SAMPLES 10 smc verify Samples inside the settle window
MODE_SETTLE_MS 1000 smc verify Settle window for fan1_manual mode writes
WRITE_RETRY_MAX 1 smc verify Window-expired write re-issued at most once
STALL_POLLS 10 supervisor L1 Motionless off-target polls → stall → AUTO + monitor-only
STALL_TACH_EPSILON_RPM 50 supervisor L1 Per-poll tach delta ≤ 50 = motionless
AUTO_RETRY_LOG_POLLS 10 supervisor Failed-AUTO-restore log rate-limit (1 ERROR per 10 polls)
OFF_TARGET_WARN_POLLS 30 supervisor Off-target dwell → WARN, repeats every 30 polls

Effective temperature

t_eff = max over valid sensors (acts on the 92 °C core, not the 80 °C average — kills the averaging defect). A sensor reading < 0 °C or > 120 °C is rejected as a failed read (None); other sensors still drive t_eff. t_eff = None only when all sensors fail.

Decisions

pub enum Decision { Observe, SetSpeed(u32), EscalateMax, ReturnToAuto }
Variant Meaning
Observe Write nothing (observe mode, or interim sensor loss keeping current output).
SetSpeed(rpm) Command this rpm (already slew-limited).
EscalateMax Overshoot guard fired — bypass slew, go to max_rpm immediately.
ReturnToAuto Sensor-loss streak exhausted — restore firmware control.

Absolute target curve (recomputed every poll, no direction gates)

With low = high − 3 (derived, saturating):

Zone Condition Target Latch effect
Cold t < low min_rpm ramping = false
Max t ≥ max max_rpm ramping = true
Ramp high ≤ t < max linear min_rpm → max_rpm ramping = true
Mid (hysteresis) low ≤ t < high ramping ? hold slew_rpm : min_rpm unchanged

Linear interpolation (exact at both endpoints, i64 math, saturating_mul on thresholds):

target = min_rpm + (t − high·1000) · (max_rpm − min_rpm) / ((max − high)·1000)

Worked defaults (high = 66, max = 86, min = 1200, max_rpm = 6200): t = 80 °C1200 + 14000·5000/20000 = 4700 rpm (before slew). t = 661200; t = 866200. No direction gates: a descending approach converges down with no ratchet (the plateau/overspeed defect is dead by construction).

Hysteresis

low = high − 3 (default 63 °C). Once ramping = true (entered ramp or max), it stays true until t < low. In the mid zone with ramping = true, the controller holds the current slew_rpm instead of flapping between min_rpm and a just-below-high linear target.

Slew limiter

The fan moves at most 750 rpm per poll toward the target, in either direction (full sweep ≈ 5 s at 1 Hz):

if target >= from { (from + 750).min(target) } else { from.saturating_sub(750).max(target) }

Overshoot guard (applies in every mode, including hold)

  • Threshold: t_eff ≥ (max − 1) °C for OVERSHOOT_POLLS = 3 consecutive polls (default: 85 °C × 3).
  • Effect: slew_rpm = max_rpm, return EscalateMax — bypasses the slew limiter immediately.
  • In hold mode the guard overrides the held speed (hold never disables guards).

Sensor-loss handling

Consecutive all-failed polls Curve Hold Observe
streak < 3 Observe (keep current output, write nothing new) Observe (keep held output) Observe
streak ≥ 3 ReturnToAuto (reset streaks, slew_rpm = min_rpm, ramping = false) ReturnToAuto Observe (tracks streak for logging only)

Never drives on garbage: interim loss holds, full streak returns the fan to the firmware.

Hold mode

step_hold(readings, held_rpm): normally SetSpeed(held_rpm); EscalateMax when the overshoot guard fires; Observe/ReturnToAuto on sensor loss exactly like curve. Clamping to the hardware band happens in the supervisor at write time (rpm.clamp(hw_min, hw_max)); the CLI pre-clamps/rejects before writing cmd.json.

MilliC arithmetic

from_c(c) = c.saturating_mul(1000); as_c() rounds half away from zero. All threshold ×1000 sites use saturating_mul (plus saturating_sub for max − 1): an absurdly low threshold lands in the max zone → the fan goes to max (degrades toward cooling).

Controller state (for the curious)

min_rpm/max_rpm, high_c/max_c/low_c, slew_rpm (starts min_rpm), ramping (starts false), loss_streak, overshoot_streak, last_t_eff. t_eff() returns the last effective temperature for status.

Clone this wiki locally