SAFETY-12: the loop thinks on a clock, not only when the number moves

run_control() ran cycle() only when the meter value differed from the previous
poll. A meter that repeats a value therefore ran no control cycles at all -
while the heartbeat kept re-emitting the last command and the firmware kept
putting it on the wire every 2 s. Layer 1 stayed fed and every indicator read
healthy, so a command computed from an old reading went on being executed
through whatever the load did next. TEL-05 measured it: 70 s at a literally
constant 350.0 W, 14 arrivals, zero cycles.

cycle_floor_s (default 15 s) is a deadline on the GAP between cycles, reset by
every cycle whichever trigger fired - not a second scheduler. On a healthy meter
it never elapses, so the cycle rate and the commanded sequence are unchanged;
test_loop.py asserts that by driving the real loop twice on one binary (a huge
cycle_floor_s reproduces the old behaviour) and diffing the writes.

15 s is bracketed: above the ~11 s worst gap on a merely-unlucky healthy meter
(~5 s arrivals, 1 s detection, one dropped poll retried on the next tick), below
meter_max_age_s and the firmware's 30 s ingest veto, and equal to stale_input_s.

A floor cycle runs the full law. Freshness is already decided upstream - past
meter_max_age_s the reading is None, which is a P_SAFETY 0 W claim - and the
integrator advances per cycle, not per second, so bounding the gap bounds the
accumulation.

Not a freeze detector: nothing here decides whether a repeated reading is true.
DOCS.md's "accepted deviation" passage is qualified rather than deleted - the
window is now bounded at 3 x cycle_floor_s instead of open-ended, and its
"nothing accumulates while it is stalled" argument is called out as having
covered windup and not staleness. Same correction in control.py's comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
glenn schrooyen
2026-08-28 20:29:16 +02:00
co-authored by Claude Opus 5
parent e2e331ea54
commit 88cd97cc7a
6 changed files with 327 additions and 28 deletions
+36 -14
View File
@@ -272,9 +272,10 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
| `step_w` | 10 | Quantisation |
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one *changed* meter reading, not a fixed period - see the note below. **Do not set to 1** |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
| `auto_start` | false | Start controlling on boot (only after commissioning) |
@@ -284,22 +285,43 @@ The specification states the saturation window as **"> 10 s"**. This add-on coun
**cycles** instead, and that is a deliberate, accepted deviation rather than an
oversight - the acceptance criterion is not met as literally written.
A cycle here is one *changed* meter reading: the controller only runs the loop when the
meter value differs from the previous poll. At the reference P1's ~5 s update rate the
default of 3 cycles is usually around 15 s, but there is **no guaranteed wall-clock
window** - a meter that repeats the same value stalls the counter for as long as it
repeats.
A cycle here is one meter update **or `cycle_floor_s` elapsing, whichever comes
first**. At the reference P1's ~5 s update rate the default of 3 cycles is usually
around 15 s, and the worst case is bounded at `3 x cycle_floor_s` = 45 s. It is still
not the 10 s the specification asks for, so the deviation stands - but it is now a
bounded number rather than an open one.
Two reasons that is acceptable:
⚠️ **This passage used to say something different, and the second half of it was
wrong.** Until SAFETY-12 the loop ran only when the meter value *changed*, so a meter
that repeated a value stalled the counter for as long as it repeated, with no upper
bound at all. That was defended here on the grounds that "a stalled counter is a
detection-latency limit and not a runaway risk - the condition that stalls it stops the
whole loop, so nothing accumulates while it is stalled". That argument covers
**integrator windup and nothing else**. The hazard it misses is **staleness**: while the
loop was stopped the heartbeat kept re-emitting the last command and the firmware kept
putting it on the wire every 2 s, so a command computed from an old reading went on
being executed through a real load change, with every indicator reading healthy.
Measured on the rig during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals,
zero control cycles.
- the control law is a pure function with no clock, which is what makes it testable
without hardware, and a seconds-based window would have to live in the controller;
- a stalled counter is a detection-latency limit and not a runaway risk. The condition
that stalls it - an unchanging meter - stops the whole loop, so nothing accumulates
while it is stalled.
The half that survives is the first one: the control law is still a pure function with
no clock, and the wall-clock window still lives in `Controller`, which has one - see
`run_control()`. What changed is that the controller now enforces a floor on the gap
between cycles, so `saturation_cycles` finally means a bounded number of seconds.
If a guaranteed window matters on your site, raise `saturation_cycles` for a fast meter,
and treat the figure as "N meter updates" rather than "N seconds".
If a tighter window matters on your site, lower `cycle_floor_s` (not below your meter's
update interval, or the floor starts firing on a healthy meter) or raise
`saturation_cycles` for a fast meter.
**This is not a freeze detector, and it deliberately cannot be one.** Nothing here
decides whether a repeated reading is true — see `unchanged_s` in the meter section for
why no threshold on that is safe at the converged operating point. The floor only stops
"the number is the same" from meaning "do not think". The consequence is worth stating
plainly: against a meter that is genuinely frozen at a wrong value, the loop will now
correct toward that wrong value instead of holding a stale command. It is bounded by
`max_w`, the arbiter and the device rating, and it is the same thing that already
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
safety property.
#### Why `target_grid_w` is not zero