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:
co-authored by
Claude Opus 5
parent
8cc9438aec
commit
aae74d7571
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user