SAFETY-12 review: name the safety claim that could not fire, and fix two figures

Review fixes.

A. test_loop.py takes 73 s, not the ~35 s claimed in three places. That number
is the whole defence against a future developer deleting a slow test, so it was
understated by 2x in exactly the place it matters. Corrected - and it is now
~95 s (measured 93 s), because this commit adds the scenario below rather than
trimming windows to make an inconvenient number smaller. Both call sites carry
an instruction to re-measure if the windows change.

B. "242 checks" was test_p1's own count quoted as the total for all five
suites. The real figure is 395 across the five, plus 14 here.

And the finding this PR should have led with. stale_input_s and the
`inputs missing -> 0 W` claim both live INSIDE cycle(), so pre-fix, on a meter
whose value happened to repeat, that claim could not fire at all. Constant
meter, SoC entity dying at t=4 s, one binary:

  pre-fix   final target 220.0 W, reason 'loop -> 220 W'
  post-fix  final target   0.0 W, reason 'safety -> 0 W'

The old controller held 220 W through the whole input outage with its status
line still reading loop -> 220 W, and layer 1 does not cover that on a
`meter_source: off` install - no P1 age sensor is published, so the firmware has
nothing to veto on, while the heartbeat keeps its watchdog fed. The pre-fix
behaviour was not "holds a stale command safely"; it was a failsafe a repeating
meter switched off. Asserted in test_loop.py, both halves.

Three points of reasoning tightened, none of them wrong before:

- the re-tune is unreachable by construction, not merely by the reset: the
  combined cycle rate is max(arrival rate, 1/floor), never the sum, and 1/15 s
  is strictly slower than the ~1/5 s arrival rate. That holds only while the
  floor exceeds the meter's update interval - and the schema minimum of 5 s is
  exactly the reference meter's interval. Flagged for validate.py, not fixed
  here.
- the ~11 s lower bracket is not only a dropped poll: a 5.0 s poller against a
  4.97 s meter beats, so about every 14 minutes the poller steps over an update
  entirely. Reaching 15 s takes a beat-skip AND a failed poll.
- the "freshness is decided upstream" guarantee is transport-conditional. On
  `meter_source: off` the grid figure comes straight from hass.number() with no
  age gate. Running the full law is still right; the claim is narrower than it
  was stated.

The 15 s default is not independently justified - it rests on the firmware's
30 s ingest veto and moves if that ever does. Said beside the constant.

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 21:14:58 +02:00
co-authored by Claude Opus 5
parent 8cc9438aec
commit aae74d7571
4 changed files with 173 additions and 50 deletions
+61 -14
View File
@@ -76,6 +76,26 @@ indicator read healthy, and a command computed from an old reading kept being
executed through whatever the load did next. Measured on the rig during TEL-05:
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
**It was worse than a latency limit: it was a safety claim that could not
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
never called again - so that claim had no way to run. Constant meter, SoC entity
dying at t=4 s, same binary:
```
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
final target: 220.0 W reason: 'loop -> 220 W'
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
final target: 0.0 W reason: 'safety -> 0 W'
```
The pre-fix controller held 220 W through the entire input outage with its
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
behaviour was not "holds a stale command safely" - it was a failsafe that a
repeating meter switched off. `test_loop.py` asserts both halves.
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
meter it never elapses, so the cycle rate, the write sequence and therefore the
@@ -84,6 +104,16 @@ per-cycle gain the tuning was measured against are unchanged - asserted in
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
commanded values.
The reset is not the only thing holding that up, and the other argument is the
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
even a hypothetical floor racing the arrivals could not exceed the nominal
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
while `cycle_floor_s` stays above the meter's update interval, and the schema
minimum is 5 s - which is exactly the reference meter's interval. Nothing
enforces the relationship; `validate.py` is the obvious home for it and does not
have it yet.
Observed over six real minutes on the same rig, one binary, meter held at a
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
the command stuck at 220 W against a 350 W import for the rest of the window.
@@ -96,21 +126,38 @@ both sides, the same values in the same order.** The heartbeat write count never
moves; the only writes the fix adds are the ten the loop was supposed to be
making all along.
15 s is bracketed, not round. Below it: arrivals are ~5 s (NOTES.md measures
4.97 s), detected up to 1 s late by the 1 s poll, and one dropped poll is
retried on the next tick - about 11 s worst case on a meter that is merely
unlucky, which the floor must not fire on. Above it: `meter_max_age_s` is 30 s,
past which there is no reading to evaluate, and the firmware's ingest veto is
also 30 s - so the loop is guaranteed to have re-decided at least twice inside
any window in which layer 1 might otherwise be the only thing deciding. It is
also exactly `stale_input_s`, this system's existing answer to "how long may we
hold without re-deciding".
15 s is bracketed, not round.
A floor cycle runs the **full** law on the same inputs, integrator included. The
freshness question is already answered upstream and answered harder - past
`meter_max_age_s` the reading is None, which is "inputs missing", which is a
`P_SAFETY` 0 W claim - so a floor cycle can only ever run on a reading this
system already calls fresh. The integrator advances per *cycle*, not per second,
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
the poller steps over an update entirely - a ~10 s gap between changed values on
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
sits above the beat-skip so it does not fire on that alone; what it takes to
reach 15 s is a beat-skip *and* a failed poll coinciding.
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
guaranteed to have re-decided at least twice inside any window in which layer 1
might otherwise be the only thing deciding. ⚠️ The default is therefore not
independently justified at 15: if the firmware's veto ever moves, this moves
with it. Noted beside the constant in `main.py`.
It is also exactly `stale_input_s`, this system's existing answer to "how long
may we hold without re-deciding".
A floor cycle runs the **full** law on the same inputs, integrator included. In
P1 mode the freshness question is already answered upstream and answered harder
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
this system already calls fresh. ⚠️ That is transport-conditional: with
`meter_source: off`, `read_inputs()` takes the grid figure straight from
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
gone quiet reads as current on that path, with or without this change. Running
the full law is still right there - the alternative is a degraded second law
reasoning about an age nobody measured - but the floor does not make that path
fresh. The integrator advances per *cycle*, not per second,
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
that. A gentler second law for floor cycles would be a second set of dynamics
nobody can test on this hardware.