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

Merged
admin merged 4 commits from safety-12-loop-floor into release/1.0 2026-08-28 21:18:21 +02:00
Owner

The defect

main.py ran cycle() only when the meter value differed from the previous poll:

if self.grid != last_grid:
    await self.cycle()

A meter that arrives with an unchanged value ran no control cycle. The heartbeat kept re-emitting the last command and the firmware's 2 s tick kept putting it on the wire, so layer 1 stayed satisfied and every indicator read healthy - while the loop had not evaluated and the load may have moved underneath it. TEL-05 measured it on the rig: 70 s at a literally constant 350.0 W with 14 arrivals, zero cycles.

DOCS.md defended this as an accepted deviation on the grounds that "a stalled counter is a detection-latency limit and not a runaway risk... nothing accumulates while it is stalled". That argument covers integrator windup and does not cover staleness, which was the actual hazard.

The fix

cycle_floor_s, default 15 s: a deadline on the gap between cycles, reset by every cycle whichever trigger fired. Not a second scheduler - on a healthy changing meter it never elapses and not one existing cycle moves.

Three decisions

1. Where the floor belongs. A deadline beside the arrival handler, not a restructure to a clock-driven cycle() with arrivals merely updating inputs. The clean version is cleaner and I did not take it: it changes the cycle rate on every install, and gain is defined per cycle and was measured on real hardware - a different cycle rate is a different effective integral gain per second, i.e. a re-tune of a loop that regulates an unattended battery. The deadline form is strictly additive and provably inert on the healthy path: same trigger, same order, one extra or overdue.

2. What a cycle with nothing new does. The full law, integrator included, no degraded mode. Freshness is already decided upstream and decided harder: past meter_max_age_s (30 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, and cannot run the PI on a 60 s old figure because at 60 s there is no figure. The integrator advances per cycle, not per second, so bounding the gap bounds the accumulation, under the same integrator_max_w as every other cycle. A gentler second law for floor cycles would be a second set of dynamics nobody can test on this hardware.

3. DOCS.md:283-302. Qualified, not deleted. The deviation survives - the window is still counted in cycles, and 3 x 15 s is still not the specification's 10 s - but it is now bounded at 45 s instead of open-ended. The passage now says what it used to say, why that was wrong, and what part of it stands (the control law is still clockless; the clock lives in Controller, exactly as the old comment said it should). The same wrong argument in control.py's saturation comment is corrected too.

Why 15 s and not a round number

Bracketed from both sides, from measured figures:

  • Floor from below (~11 s). Arrivals are ~5 s (NOTES.md measures 4.97 s), detected up to 1 s late by the 1 s poll, and a dropped poll is retried on the next tick with no backoff. Anything at or under ~12 s starts adding cycles on a meter that is merely unlucky.
  • Ceiling from above (30 s). meter_max_age_s is 30 s, past which there is nothing to evaluate, and the firmware's ingest veto is also 30 s - at 15 s 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", so a site that shortens one has a reason to shorten the other.

What this is not

Not a freeze detector. TEL-05 established one cannot be built here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move while the power figure legitimately repeats, so any threshold on unchanged_s false-trips at the exact operating point we run at. Nothing here decides whether a repeated reading is true; it only stops "the number is the same" from meaning "do not think".

The consequence, stated plainly and documented: against a meter genuinely frozen at a wrong value the loop will now correct toward that wrong value instead of holding a stale command - bounded by max_w, the arbiter and the device rating. That was already what happened to a lying meter whose value merely jittered. Bit-exact repetition was never a safety property.

Verification

test_loop.py is new and drives the real run_control() in real wall-clock time (~95 s, measured 93 s - eight runs of the real loop; the thing under test is a wall-clock floor inside a loop that sleeps 1 s, so mocking the clock would leave the assertion testing the mock). cycle_floor_s has no lower bound in code, so a huge value reproduces the pre-fix behaviour exactly - every claim is an A/B on one binary.

  • pre-fix, constant meter, 12 s: 1 cycle (at startup) and nothing after, while writes continue
  • post-fix, same meter: cycles throughout, worst gap 3.0 s against a 3 s floor
  • AC3, changing meter: identical cycle count and an identical sequence of commanded values, pre-fix vs post-fix
  • AC3, meter parked at target: the loop evaluates where it previously did not, and the deadband still holds the command - writes byte-identical to pre-fix

All five existing suites pass unchanged - 395 checks (test_control 55, test_validate 59, test_p1 242, test_maintenance 21, test_arbiter 18); 242 was test_p1's own count misquoted as the total. test_loop.py adds 14.

No add-on stop/start was needed and none was performed: the loop cadence is host-side logic with no hardware dependence, so nothing was deployed to the live install and the running 0.4.0 was never interrupted.

Observed, not asserted: six real minutes on the rig

Same binary, cycle_floor_s huge for the pre-fix run. Meter held at a literally constant 350.0 W for 360 s:

cycles worst gap writes
pre-fix 1 (at startup, then nothing) - 37
post-fix 24 15.14 s 46

Pre-fix the command sat at 220 W against a 350 W import for the whole window. Post-fix the ordinary PI ramp runs: 220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W - one step per cycle, rail at t=136 s, flat after. Those ten value changes are the entire difference in the write count; the 36 heartbeat writes are identical on both sides.

Three minutes with the meter parked at the target (-10 W, inside the deadband) - the converged operating point TEL-05 warned about:

cycles writes
pre-fix 1 19
post-fix 12 19

Three minutes on a normally-changing meter: 36 cycles and 49 writes on both sides, the same values in the same order.

So: the loop thinks more, and the wire sees exactly what it saw before except where the loop legitimately changed its mind.


Correction: this is a safety fix, not a latency fix

The section above undersold the change, and the review caught it. stale_input_s and the inputs missing -> 0 W claim both live inside cycle(). Pre-fix, on a meter whose value happened to repeat, cycle() was never called again - so that safety claim could not fire at all.

Constant meter, SoC entity dying at t=4 s, one 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. Layer 1 does not cover it either 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 pre-fix behaviour was not "holds a stale command safely". It was a failsafe that a repeating meter switched off. Both halves are now asserted in test_loop.py.

Three points of reasoning tightened

  • 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; at 1/15 s the floor 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, i.e. the one configurable value that would double the cycle rate. Flagged for validate.py, deliberately not fixed here.
  • The ~11 s lower bracket arises more routinely than "a dropped poll". A 5.0 s poller against a 4.97 s meter beats, so roughly every ~14 minutes the poller steps over an update entirely - a ~10 s gap on a perfectly healthy meter, plus up to 1 s for the tick. Reaching 15 s takes a beat-skip and a failed poll coinciding.
  • "Freshness is decided upstream" is transport-conditional. It holds in P1 mode. On meter_source: off the grid figure comes straight from hass.number() with no age gate at all. Running the full law is still right - the alternative is a degraded second law reasoning about an age nobody measured - but the floor does not make that path fresh, and the PR should not have implied otherwise.

Ceiling dependency: the 15 s default is not independently justified. It rests on the firmware's 30 s ingest veto and on meter_max_age_s; if the veto ever moves, this default moves with it. Now stated beside the constant in main.py. TEL-06 does not move it.

Mutation-checked

The naive second-scheduler form (a periodic tick not reset by arrival cycles) was substituted into main.py and the suite re-run. It fails on exactly the two checks that matter:

FAIL a changing meter runs the same number of cycles as before the fix
FAIL ...commanding exactly the same sequence of values
## The defect `main.py` ran `cycle()` only when the meter value differed from the previous poll: ```python if self.grid != last_grid: await self.cycle() ``` A meter that arrives with an unchanged value ran **no control cycle**. The heartbeat kept re-emitting the last command and the firmware's 2 s tick kept putting it on the wire, so layer 1 stayed satisfied and every indicator read healthy - while the loop had not evaluated and the load may have moved underneath it. TEL-05 measured it on the rig: **70 s at a literally constant 350.0 W with 14 arrivals, zero cycles**. `DOCS.md` defended this as an accepted deviation on the grounds that *"a stalled counter is a detection-latency limit and not a runaway risk... nothing accumulates while it is stalled"*. That argument covers **integrator windup** and does not cover **staleness**, which was the actual hazard. ## The fix `cycle_floor_s`, default **15 s**: a deadline on the **gap between cycles**, reset by every cycle whichever trigger fired. Not a second scheduler - on a healthy changing meter it never elapses and not one existing cycle moves. ### Three decisions **1. Where the floor belongs.** A deadline beside the arrival handler, *not* a restructure to a clock-driven `cycle()` with arrivals merely updating inputs. The clean version is cleaner and I did not take it: it changes the cycle rate on every install, and `gain` is defined per *cycle* and was measured on real hardware - a different cycle rate is a different effective integral gain per second, i.e. a re-tune of a loop that regulates an unattended battery. The deadline form is strictly additive and provably inert on the healthy path: same trigger, same order, one extra `or overdue`. **2. What a cycle with nothing new does.** The **full** law, integrator included, no degraded mode. Freshness is already decided upstream and decided harder: past `meter_max_age_s` (30 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, and cannot run the PI on a 60 s old figure because at 60 s there is no figure. The integrator advances per *cycle*, not per second, so bounding the gap bounds the accumulation, under the same `integrator_max_w` as every other cycle. A gentler second law for floor cycles would be a second set of dynamics nobody can test on this hardware. **3. `DOCS.md:283-302`.** **Qualified, not deleted.** The deviation survives - the window is still counted in cycles, and 3 x 15 s is still not the specification's 10 s - but it is now bounded at 45 s instead of open-ended. The passage now says what it used to say, why that was wrong, and what part of it stands (the control law is still clockless; the clock lives in `Controller`, exactly as the old comment said it should). The same wrong argument in `control.py`'s saturation comment is corrected too. ### Why 15 s and not a round number Bracketed from both sides, from measured figures: - **Floor from below (~11 s).** Arrivals are ~5 s (NOTES.md measures 4.97 s), detected up to 1 s late by the 1 s poll, and a dropped poll is retried on the next tick with no backoff. Anything at or under ~12 s starts adding cycles on a meter that is merely unlucky. - **Ceiling from above (30 s).** `meter_max_age_s` is 30 s, past which there is nothing to evaluate, and the firmware's ingest veto is also 30 s - at 15 s 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", so a site that shortens one has a reason to shorten the other. ### What this is not **Not a freeze detector.** TEL-05 established one cannot be built here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move while the power figure legitimately repeats, so any threshold on `unchanged_s` false-trips at the exact operating point we run at. Nothing here decides whether a repeated reading is *true*; it only stops "the number is the same" from meaning "do not think". The consequence, stated plainly and documented: against a meter genuinely frozen at a wrong value the loop will now correct toward that wrong value instead of holding a stale command - bounded by `max_w`, the arbiter and the device rating. That was already what happened to a lying meter whose value merely jittered. Bit-exact repetition was never a safety property. ## Verification `test_loop.py` is new and drives the **real** `run_control()` in real wall-clock time (**~95 s, measured 93 s** - eight runs of the real loop; the thing under test is a wall-clock floor inside a loop that sleeps 1 s, so mocking the clock would leave the assertion testing the mock). `cycle_floor_s` has no lower bound in code, so a huge value reproduces the pre-fix behaviour exactly - every claim is an A/B on one binary. - pre-fix, constant meter, 12 s: **1 cycle** (at startup) and nothing after, while writes continue - post-fix, same meter: cycles throughout, **worst gap 3.0 s** against a 3 s floor - **AC3, changing meter: identical cycle count and an identical sequence of commanded values**, pre-fix vs post-fix - **AC3, meter parked at target:** the loop evaluates where it previously did not, and the deadband still holds the command - **writes byte-identical** to pre-fix All five existing suites pass unchanged - **395 checks** (`test_control` 55, `test_validate` 59, `test_p1` 242, `test_maintenance` 21, `test_arbiter` 18); 242 was `test_p1`'s own count misquoted as the total. `test_loop.py` adds 14. No add-on stop/start was needed and none was performed: the loop cadence is host-side logic with no hardware dependence, so nothing was deployed to the live install and the running 0.4.0 was never interrupted. ### Observed, not asserted: six real minutes on the rig Same binary, `cycle_floor_s` huge for the pre-fix run. Meter held at a literally constant 350.0 W for 360 s: | | cycles | worst gap | writes | |---|---|---|---| | pre-fix | **1** (at startup, then nothing) | - | 37 | | post-fix | **24** | 15.14 s | 46 | Pre-fix the command sat at 220 W against a 350 W import for the whole window. Post-fix the ordinary PI ramp runs: 220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W - one step per cycle, rail at t=136 s, flat after. Those ten value changes are the entire difference in the write count; the 36 heartbeat writes are identical on both sides. Three minutes with the meter parked at the target (`-10 W`, inside the deadband) - the converged operating point TEL-05 warned about: | | cycles | writes | |---|---|---| | pre-fix | 1 | **19** | | post-fix | 12 | **19** | Three minutes on a normally-changing meter: **36 cycles and 49 writes on both sides, the same values in the same order.** So: the loop thinks more, and the wire sees exactly what it saw before except where the loop legitimately changed its mind. --- ## Correction: this is a safety fix, not a latency fix The section above undersold the change, and the review caught it. `stale_input_s` and the `inputs missing -> 0 W` claim both live **inside** `cycle()`. Pre-fix, on a meter whose value happened to repeat, `cycle()` was never called again - **so that safety claim could not fire at all.** Constant meter, SoC entity dying at t=4 s, one 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`. Layer 1 does not cover it either 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 pre-fix behaviour was not "holds a stale command safely". It was a failsafe that a repeating meter switched off.** Both halves are now asserted in `test_loop.py`. ## Three points of reasoning tightened - **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; at 1/15 s the floor 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**, i.e. the one configurable value that would double the cycle rate. Flagged for `validate.py`, deliberately not fixed here. - **The ~11 s lower bracket arises more routinely than "a dropped poll".** A 5.0 s poller against a 4.97 s meter beats, so roughly every ~14 minutes the poller steps over an update entirely - a ~10 s gap on a perfectly healthy meter, plus up to 1 s for the tick. Reaching 15 s takes a beat-skip **and** a failed poll coinciding. - **"Freshness is decided upstream" is transport-conditional.** It holds in P1 mode. On `meter_source: off` the grid figure comes straight from `hass.number()` with no age gate at all. Running the full law is still right - the alternative is a degraded second law reasoning about an age nobody measured - but the floor does not make that path fresh, and the PR should not have implied otherwise. **Ceiling dependency:** the 15 s default is not independently justified. It rests on the firmware's 30 s ingest veto and on `meter_max_age_s`; if the veto ever moves, this default moves with it. Now stated beside the constant in `main.py`. TEL-06 does not move it. ## Mutation-checked The naive second-scheduler form (a periodic tick not reset by arrival cycles) was substituted into `main.py` and the suite re-run. It fails on exactly the two checks that matter: ``` FAIL a changing meter runs the same number of cycles as before the fix FAIL ...commanding exactly the same sequence of values ```
admin added 1 commit 2026-08-28 20:30:18 +02:00
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
admin added 1 commit 2026-08-28 20:32:02 +02:00
The pre-deploy command listed three of the five suites; test_validate and
test_p1 were never added when they arrived. Adds those and test_loop, with a
note that test_loop's ~35 s is the cost of proving a wall-clock property about a
loop that sleeps 1 s a tick rather than a sign something is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
admin added 1 commit 2026-08-28 20:48:51 +02:00
test_loop.py proves the property in 35 s. This is the same three scenarios run
for six and three real minutes each, on one binary, and it is what AC2 and AC3
were actually asked for: 1 cycle before vs 24 after on a constant meter with the
command stuck at 220 W against a 350 W import, and byte-identical write
sequences on both the converged and the normally-changing meter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
admin added 1 commit 2026-08-28 21:15:01 +02:00
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
admin merged commit 35856a73ae into release/1.0 2026-08-28 21:18:21 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#7