-
Notifications
You must be signed in to change notification settings - Fork 0
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).
| 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 |
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.
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. |
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 °C → 1200 + 14000·5000/20000 = 4700 rpm (before slew). t = 66 →
1200; t = 86 → 6200. No direction gates: a descending approach converges
down with no ratchet (the plateau/overspeed defect is dead by construction).
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.
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) }- Threshold:
t_eff ≥ (max − 1)°C forOVERSHOOT_POLLS = 3consecutive polls (default: 85 °C × 3). - Effect:
slew_rpm = max_rpm, returnEscalateMax— bypasses the slew limiter immediately. - In hold mode the guard overrides the held speed (hold never disables guards).
| 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.
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.
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).
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.
- Home
- Installation
- CLI Reference
- Configuration
- Control Policy
- Supervisor and Run Modes
- Safety Model
- Hardware Interface
- Diagnostics (doctor)
- Plugin Surface (omafan)
- JSON Schemas
- systemd and Packaging
- Development and Testing
- Architecture and Internals
- Troubleshooting and FAQ
- Background and Verification
- afanctl-vs-mbpfan