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:
co-authored by
Claude Opus 5
parent
e2e331ea54
commit
88cd97cc7a
+36
-14
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user