A meter frozen at a wrong-but-fresh value now raises an alarm. It is still not stopped.
The failure needs no fault anywhere else. A meter that answers 200 OK with a stale document forever is arriving on every poll, so every freshness mechanism reads healthy: meter_max_age_s is reset by each response, the firmware watchdog is fed by the heartbeat and by a sample age that never climbs, and the arbiter contributes only ±max_w. The loop integrates a constant error and rails — 360 / 135 / 75 s at the live install's max_w: 5000 for a meter frozen at 350 / 1000 / ≥3000 W.
The detector (app/freeze.py) is a conjunction, not the bare unchanged_s threshold TEL-05 measured and rejected:
unchanged_s >= meter_freeze_s AND |command| within 5 % of max_w
Both terms already existed and were already on the status page. They fail for independent reasons: at the converged point the command is nowhere near the rail, and at the rail against a real load the kWh registers move within seconds.
Why 300 s, from data
Measured on sim/scenarios/p1-age-2026-08-26.jsonl.gz — 2399 s (40 min) of the live house converged, mean grid −14.2 W, 1194 polls, 735 of them repeating the previous reading (61.6 %):
distinct-reading transitions
458
median / p95 gap between distinct readings
6.0 s / 6.0 s
largest unchanged_s any sample observes
8.0 s
longest interval serving no distinct reading
15.4 s (spans an 11.4 s hole in the capture's own polling)
samples outside ±500 W
0
300 s is 37× the first figure and 19.5× the pessimistic one, and still inside the 900 s quarter that bills the capacity tariff. test_freeze.py re-measures both every run, so the prose cannot quietly stop being true.
There is also a structural reason, not just a margin: at 300 s the slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which is inside the 15 W deadband_w — so the command there is parked at 0 W and the two halves of the conjunction cannot both be true. At the schema floor of 60 s that figure becomes 60 W and only the margin is left; config.yaml says so.
Honest about what the capture proves
⚠️The converged capture alone does not refute a bare unchanged_s threshold. On this house's meter the power figure jitters at 1 W, so worst unchanged_s is 8 s — a bare 300 s rule is silent on that capture too. The case that does refute it is the one p1.py describes and no capture of this meter contains, and it is reconstructed in the check (part 2b) from the operating point the capture measures.
Notify, do not trip
An HA persistent notification (fixed notification_id, so a repeat replaces rather than stacks), an ERROR log line, a UI event, and a red status row. No claim, no latch, no 0 W. A false trip parks the battery and the operator's next move is to switch the guard off; a false notification costs an alert.
⚠️Only meter_source: homewizard_local is covered — it is the only transport that sees the meter's own document. Elsewhere the status page says NOT COVERED rather than pretending the guard is armed.
DOCS.md now states the capacity-tariff exposure this does not close: peak_cap_w caps maintenance charging and nothing caps the loop, so a lying meter drives a 5 kW unattended import and ≥15 min of that sets the month's billed peak (measured: one 4998 W quarter moved a monthly peak 4832 → 5013 W). That cap is SAFETY-07.
Verification transcript
On the rig — hwsim --fault freeze, in the live containers
Run under sim/riglock.py against the real goodwe-sim compose project on 192.168.2.147; lock acquired and released cleanly. The shipped app/{p1,control,freeze}.py were staged into goodwe-sim-plant (sha256 printed below, so there is no doubt what ran) and poll that container's own hwsim over its own loopback, with plantsim driving it and the fault applied through the same /sim/ctl seam inject.py's hwctl() speaks.
rig lock: acquired (SAFETY-18: hwsim --fault freeze + negative twin)
== rig containers ==
goodwe-sim-plant Up 26 hours
goodwe-sim-ha Up 26 hours
== plant is live: the last lines of its own log ==
[00:46:56][D][uart_debug:113]: >>> F7 10 05 6E 00 02 04 00 03 00 00 A6 D0
[00:46:56][D][safety01:765]: tick: 0 W, failsafe=1 cause=meter_stale authorised=0 pairs=47428
[00:46:56][D][uart_debug:113]: <<< F7 10 05 6E 00 02 34 4F
== staging the add-on under test into the container ==
sha256 of what is actually about to run:
2d035e44f3d103feba1d5b698ec3dff86c3b137acb415f081bc197fd66de43ca app/freeze.py
f0ce77b49bb6a51bf2e72e5a290bef30b4be01506b479ec95f21d9af0549e3af app/p1.py
026bce92a6ea18ddba86267ae6128edbaaf3c100a743199e543d7c294e0ae6fb app/control.py
== run ==
rig: hwsim at http://127.0.0.1:80, max_w 5000, hold_s 10
phase 1 - NEGATIVE TWIN: real railed load, meter healthy
meter ctl load_offset=8000 -> {'fault': None, 'load_offset': 8000.0}
moving: 26 polls, 26 distinct documents served, worst unchanged 0.0 s, peak |command| 5000 W (rail 4750 W), alarm at never
t= 0.0s grid=+10130.0 W cmd= +1000 W unchanged= 0.0 s
t= 4.0s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 8.0s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 12.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 16.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 20.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 24.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
ok the transport really polled the rig's meter
ok the meter really was moving (many distinct documents)
ok the command really did reach the rail
ok a REAL railed condition on the rig raises NO alarm
ok ...because unchanged_s never came near the hold
phase 2 - THE FAULT: hwsim --fault freeze
meter ctl load_offset=0 -> {'fault': None, 'load_offset': 0.0}
meter ctl fault=freeze -> {'fault': 'freeze', 'load_offset': 0.0}
freeze: 26 polls, 1 distinct documents served, worst unchanged 25.1 s, peak |command| 5000 W (rail 4750 W), alarm at 10.1 s
t= 0.0s grid= +2130.0 W cmd= +1000 W unchanged= 0.0 s
t= 4.0s grid= +2130.0 W cmd= +5000 W unchanged= 4.0 s
t= 8.0s grid= +2130.0 W cmd= +5000 W unchanged= 8.0 s
t= 12.1s grid= +2130.0 W cmd= +5000 W unchanged= 12.1 s
t= 16.1s grid= +2130.0 W cmd= +5000 W unchanged= 16.1 s
t= 20.1s grid= +2130.0 W cmd= +5000 W unchanged= 20.1 s
t= 24.1s grid= +2130.0 W cmd= +5000 W unchanged= 24.1 s
ok hwsim served ONE document for the whole window
ok ...and it never changed across the phase
ok the transport kept polling it happily (200 OK every time)
ok the loop wound to the rail against the frozen reading
ok the detector ALARMED on the rig's frozen meter
ok ...only after the hold elapsed, not on the first railed poll
reason: METER_FROZEN: the meter has answered 25 s without its measurement changing
while the command stands at +5000 W, at the 5000 W rail
(limits: unchanged over 10 s, command over 4750 W)
meter ctl fault= -> {'fault': None, 'load_offset': 0.0}
meter ctl load_offset=0 -> {'fault': None, 'load_offset': 0.0}
rig: all checks passed
== plant still live afterwards ==
planner: grid= +2130.0 -> setpoint= +5000.0
rig lock: released
The fault and load_offset are cleared in a finally, so the rig is left as it was found. hold_s is 10 s rather than the shipped 300 because unchanged_s is wall-clock monotonic and the transport really polls — the threshold is config, the mechanism is what the rig is here to exercise.
⚠️ The rig found something the stub could not, and it changes an argument
In the negative twin — +8000 W of house load, healthy meter, correctly railed command — active_power_w did not move at all. It sat at exactly 10130.0 W for 26 consecutive polls. The only thing holding unchanged_s at 0.0 s was the kWh registers advancing.
So the register terms in measurement_fingerprint() are not belt-and-braces for this detector — they are the entire negative case. A freeze detector fingerprinted on the power figure alone would have raised a false alarm on that run: healthy meter, real load, correct command. That is exactly how a safety guard gets switched off. Now recorded in freeze.py so nobody simplifies the fingerprint without meeting that run first.
It also means the committed replay in part 2 is more conservative than I claimed, not less: it is power-only, and on the rig the power figure is the half that stands still.
⚠️riglock status reports UNKNOWN on a Windows host without DOCKER_HOST
Worth knowing, because it cost this PR a wrong caveat in its first revision. riglock.py shells out to the local docker CLI; on Windows that resolves to a Docker Desktop npipe which is not running, and the result is:
rig: UNKNOWN - unknown - failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine
That is indistinguishable from a genuinely absent rig, and it is not one. The rig lives on the homelab box and was up the whole time. UNKNOWN is the tool correctly declining to observe — its own docstring is emphatic that silence is not consent — but the caller still has to not read it as "no rig". With the host set:
$ DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status
rig: FREE
Noted in test_freeze.py, where whoever reproduces this will be looking.
Reproducing it
The driver is deliberately not committed here: the house convention is that rig harnesses live in the infra repo beside sim/replay_sign.py, so it should land there rather than in the add-on. It stages app/{__init__,p1,control,freeze}.py into goodwe-sim-plant, polls http://127.0.0.1:80 through the real HomeWizardLocalSource, drives control.compute() at the shipped defaults with max_w: 5000, and applies load_offset / fault=freeze through /sim/ctl.
Portable half — python3 test_freeze.py
predicate
ok both terms true -> frozen
ok unchanged long, command NOT railed -> silent (TEL-05's case)
ok command railed, meter still moving -> silent (a real 8 kW load)
ok neither -> silent
ok one second under the hold is still silent
ok just under the rail fraction is still silent
ok at the rail fraction exactly, it fires
ok the rail scales with max_w, not with a fixed watt figure
ok a charge rail counts too - the runaway can be either sign
ok no transport signal (unchanged_s None) -> silent, never assumed frozen
ok no command yet -> silent
ok rail_w tracks RAIL_FRAC
the edge
ok rises exactly once per episode
ok two episodes counted
ok clearing the condition clears the alarm state
ok the reason names both terms
converged replay - scenarios/p1-age-2026-08-26.jsonl.gz
ok the capture loaded
max_w 2000: worst |command| 1030 W (rail 1900 W), worst unchanged 8.0 s (hold 300 s), episodes 0
ok max_w 2000: never alarms on the converged capture
ok max_w 2000: and the rail term had margin to spare
max_w 5000: worst |command| 1030 W (rail 4750 W), worst unchanged 8.0 s (hold 300 s), episodes 0
ok max_w 5000: never alarms on the converged capture
ok max_w 5000: and the rail term had margin to spare
longest unchanged_s any sample would observe: 8.0 s
longest interval serving no distinct reading: 15.4 s (spans an 11.4 s hole in the capture's own polling)
ok worst observed unchanged_s is the 8 s the docs quote
ok longest distinct-reading interval is the 15.4 s the docs quote
ok 300 s is at least 15x the worst of the two
TEL-05's low-power case - a converged meter that legitimately repeats
an hour at -14.2 W: worst |command| 0 W, unchanged_s reaches 3599 s, episodes 0
ok the bare unchanged_s threshold TEL-05 rejected WOULD fire here
ok the conjunction does not - this is what the rail term buys
ok ...and the command stayed nowhere near the rail
ok a healthy meter slow enough to look unchanged is inside the deadband
ok ...and the shipped law really does park the command there
ok ...and the same replay DOES alarm once both terms are made reachable
frozen meter over HTTP - using the rig hwsim at C:\Users\glenn\Documents\goodwe\sim\hwsim.py
freeze: 16 polls, worst unchanged 15.2 s, worst |command| 5000 W, alarm at 10.1 s
ok the transport really polled a live server
ok a frozen meter raises the alarm
ok ...and only after the hold, not on the first railed poll
moving: 16 polls, worst unchanged 0.0 s, worst |command| 5000 W, alarm at never
ok the moving meter also drove the command to the rail
ok a REAL railed condition against a live meter stays silent
ok ...because the meter kept moving
the wiring - Controller.cycle() actually raises the notification
frozen at +4000 W: command ran to 5000 W, 1 notification(s), 1 red status row(s)
ok the shipped cycle() raises exactly one HA notification
ok ...with a fixed notification_id so repeats replace, not stack
ok ...and it says the controller is still running
ok the status page carries a failed row saying so
ok the event log names it
ok and it does NOT command 0 W - this cut is an alarm only
ok wired up, TEL-05's converged case still raises nothing
ok wired up, a real railed load against a moving meter raises nothing
ok an uncovered transport (no unchanged_s) raises nothing, and says so
all checks passed
This is the committed, always-runnable half. Its part 3 drives the rig's own hwsim with fault = "freeze" in-process when the infra repo is reachable (GOODWE_SIM=.../goodwe/sim), and an equivalent local stub otherwise; both were run and both pass. It stands in for the containerised run above, so the check needs no rig to bite.
Red — every mutation, run expecting failure
M1 — rail term removed (the bare unchanged_s threshold TEL-05 rejected)
FAIL unchanged long, command NOT railed -> silent (TEL-05's case)
FAIL just under the rail fraction is still silent
FAIL the rail scales with max_w, not with a fixed watt figure
FAIL the conjunction does not - this is what the rail term buys
4 FAILED
M2 — unchanged term removed (alarm on any railed command)
FAIL command railed, meter still moving -> silent (a real 8 kW load)
FAIL one second under the hold is still silent
FAIL rises exactly once per episode
FAIL two episodes counted
FAIL clearing the condition clears the alarm state
FAIL a REAL railed condition against a live meter stays silent
6 FAILED
M3 — predicate hard-wired False (the detector is inert)
FAIL both terms true -> frozen
FAIL at the rail fraction exactly, it fires
FAIL the rail scales with max_w, not with a fixed watt figure
FAIL a charge rail counts too - the runaway can be either sign
FAIL rises exactly once per episode
FAIL two episodes counted
FAIL the reason names both terms
FAIL ...and the same replay DOES alarm once both terms are made reachable
FAIL a frozen meter raises the alarm
9 FAILED
M4 — hold_s silently doubled (too slow for the frozen-meter run)
FAIL both terms true -> frozen
FAIL rises exactly once per episode
FAIL two episodes counted
FAIL the reason names both terms
FAIL a frozen meter raises the alarm
5 FAILED
M5 — check_meter_frozen() no longer called from cycle() (correct detector, wired to nothing)
FAIL the shipped cycle() raises exactly one HA notification
FAIL ...with a fixed notification_id so repeats replace, not stack
FAIL ...and it says the controller is still running
FAIL the status page carries a failed row saying so
FAIL the event log names it
5 FAILED
M6 — notification_id made unique per episode (notifications stack)
FAIL ...with a fixed notification_id so repeats replace, not stack
1 FAILED
Two defects were found this way and fixed rather than papered over:
the converged replay initially graded the detector only on cycles, and an arrival-triggered cycle has unchanged_s == 0 by construction — it was grading the one sample that cannot fire. It now evaluates on every poll, which is strictly harsher than the shipped wiring;
the wiring harness's fake plant did not track the command, so the saturation freeze pinned it at 3000 W and the runaway under test never happened. The check passed for the wrong reason until the plant was made to track.
A meter frozen at a wrong-but-fresh value is detected — the conjunction from the review.
It does not fire at the converged operating point, verified against the real recorded data, plus the reconstructed TEL-05 case the capture cannot contain (with the rejected design shown firing there).
It fires on a genuinely frozen meter, verified on the rig — hwsim --fault freeze in the live goodwe-sim-plant container, under sim/riglock.py, lock released cleanly. Plus its negative twin on the same rig, which is the half that could have failed. (An earlier revision of this PR carried a caveat saying the rig was unavailable. That caveat was wrong — see the DOCKER_HOST note above.)
Notification only in the first cut.
Capacity-tariff exposure stated in DOCS.md (§ "Capacity-tariff exposure of the grid-following loop"), including the table of what exists and what does not.
What the reviewer should attack hardest
RAIL_FRAC = 0.95 and the choice to key on the emitted command. SAFETY-03's i_clamped boolean would make "at the rail" a fact rather than an inference; it is not built and I did not build it. I would have used it if it existed — the 5 % slack exists only because write_setpoint() can clip max_w against the device rating, and a real flag would remove that guess entirely.
The rail term is not gated on following. Argued from physics (a maintenance charge pushes kilowatts, so the first term excludes it) rather than from bookkeeping. That argument is written down in freeze.py; it has not been exercised against a real maintenance cycle, because the maintenance cycle has never run for real.
Detection lands at max(hold_s, time-to-rail) — 300–360 s at max_w: 5000, and the rail is reached first, from 75 s. A notification is not a brake. If the euro figure is the reason this is P1, this cut does not collect it; SAFETY-07 does.
unchanged_s is time.monotonic()-based and lives on the transport. A poll task that dies stops updating it, and unchanged_s then climbs forever while p1.stale also goes true — the stale path already commands 0 W, so the command cannot be at the rail and the conjunction cannot fire. I believe that composition is safe; it is reasoned, not measured.
Coverage is homewizard_local only. On ha_dsmr / mqtt_p1 / ha_signed / off the exposure is entirely uncovered and the status page says so. If any real install runs one of those, this ticket has not helped it.
The register terms in measurement_fingerprint() are now load-bearing for this detector, not merely for unchanged_s in general. The rig run above is the evidence. If anyone ever trims that fingerprint to the power figure, this detector starts false-alarming on healthy railed loads and nothing in test_p1.py would say so.
A new 27 KB binary fixture (goodwe_controller/scenarios/p1-age-2026-08-26.jsonl.gz) is committed here, copied from the infra repo so the check is self-contained. The Dockerfile copies only app/, so it does not ship in the image — but it is now a second copy of a capture that lives elsewhere, and if that is unwanted the test can read it from the infra repo instead.
## What this does
A meter frozen at a wrong-but-fresh value now raises an alarm. **It is still not stopped.**
The failure needs no fault anywhere else. A meter that answers `200 OK` with a stale document forever is *arriving* on every poll, so every freshness mechanism reads healthy: `meter_max_age_s` is reset by each response, the firmware watchdog is fed by the heartbeat and by a sample age that never climbs, and the arbiter contributes only ±`max_w`. The loop integrates a constant error and rails — **360 / 135 / 75 s** at the live install's `max_w: 5000` for a meter frozen at 350 / 1000 / ≥3000 W.
The detector (`app/freeze.py`) is a **conjunction**, not the bare `unchanged_s` threshold TEL-05 measured and rejected:
```
unchanged_s >= meter_freeze_s AND |command| within 5 % of max_w
```
Both terms already existed and were already on the status page. They fail for independent reasons: at the converged point the command is nowhere near the rail, and at the rail against a real load the kWh registers move within seconds.
### Why 300 s, from data
Measured on `sim/scenarios/p1-age-2026-08-26.jsonl.gz` — 2399 s (40 min) of the live house converged, mean grid −14.2 W, 1194 polls, **735 of them repeating the previous reading (61.6 %)**:
| | |
|---|---|
| distinct-reading transitions | 458 |
| median / p95 gap between distinct readings | 6.0 s / 6.0 s |
| **largest `unchanged_s` any sample observes** | **8.0 s** |
| longest interval serving no distinct reading | 15.4 s (spans an 11.4 s hole in the capture's own polling) |
| samples outside ±500 W | 0 |
300 s is 37× the first figure and 19.5× the pessimistic one, and still inside the 900 s quarter that bills the capacity tariff. `test_freeze.py` re-measures both every run, so the prose cannot quietly stop being true.
There is also a **structural** reason, not just a margin: at 300 s the slowest a *healthy* 1 Wh register can tick and still look unchanged is 12 W, which is inside the 15 W `deadband_w` — so the command there is parked at **0 W** and the two halves of the conjunction cannot both be true. At the schema floor of 60 s that figure becomes 60 W and only the margin is left; `config.yaml` says so.
### Honest about what the capture proves
⚠️ **The converged capture alone does not refute a bare `unchanged_s` threshold.** On this house's meter the power figure jitters at 1 W, so worst `unchanged_s` is 8 s — a bare 300 s rule is silent on that capture too. The case that *does* refute it is the one `p1.py` describes and no capture of this meter contains, and it is reconstructed in the check (part 2b) from the operating point the capture measures.
### Notify, do not trip
An HA persistent notification (fixed `notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line, a UI event, and a red status row. **No claim, no latch, no 0 W.** A false trip parks the battery and the operator's next move is to switch the guard off; a false notification costs an alert.
⚠️ **Only `meter_source: homewizard_local` is covered** — it is the only transport that sees the meter's own document. Elsewhere the status page says **NOT COVERED** rather than pretending the guard is armed.
`DOCS.md` now states the capacity-tariff exposure this does **not** close: `peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying meter drives a 5 kW unattended import and ≥15 min of that sets the month's billed peak (measured: one 4998 W quarter moved a monthly peak 4832 → 5013 W). That cap is SAFETY-07.
---
## Verification transcript
### On the rig — `hwsim --fault freeze`, in the live containers
Run under `sim/riglock.py` against the real `goodwe-sim` compose project on 192.168.2.147; lock acquired and released cleanly. The shipped `app/{p1,control,freeze}.py` were staged into `goodwe-sim-plant` (sha256 printed below, so there is no doubt what ran) and poll **that container's own hwsim over its own loopback**, with `plantsim` driving it and the fault applied through the same `/sim/ctl` seam `inject.py`'s `hwctl()` speaks.
```
rig lock: acquired (SAFETY-18: hwsim --fault freeze + negative twin)
== rig containers ==
goodwe-sim-plant Up 26 hours
goodwe-sim-ha Up 26 hours
== plant is live: the last lines of its own log ==
[00:46:56][D][uart_debug:113]: >>> F7 10 05 6E 00 02 04 00 03 00 00 A6 D0
[00:46:56][D][safety01:765]: tick: 0 W, failsafe=1 cause=meter_stale authorised=0 pairs=47428
[00:46:56][D][uart_debug:113]: <<< F7 10 05 6E 00 02 34 4F
== staging the add-on under test into the container ==
sha256 of what is actually about to run:
2d035e44f3d103feba1d5b698ec3dff86c3b137acb415f081bc197fd66de43ca app/freeze.py
f0ce77b49bb6a51bf2e72e5a290bef30b4be01506b479ec95f21d9af0549e3af app/p1.py
026bce92a6ea18ddba86267ae6128edbaaf3c100a743199e543d7c294e0ae6fb app/control.py
== run ==
rig: hwsim at http://127.0.0.1:80, max_w 5000, hold_s 10
phase 1 - NEGATIVE TWIN: real railed load, meter healthy
meter ctl load_offset=8000 -> {'fault': None, 'load_offset': 8000.0}
moving: 26 polls, 26 distinct documents served, worst unchanged 0.0 s, peak |command| 5000 W (rail 4750 W), alarm at never
t= 0.0s grid=+10130.0 W cmd= +1000 W unchanged= 0.0 s
t= 4.0s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 8.0s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 12.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 16.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 20.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
t= 24.1s grid=+10130.0 W cmd= +5000 W unchanged= 0.0 s
ok the transport really polled the rig's meter
ok the meter really was moving (many distinct documents)
ok the command really did reach the rail
ok a REAL railed condition on the rig raises NO alarm
ok ...because unchanged_s never came near the hold
phase 2 - THE FAULT: hwsim --fault freeze
meter ctl load_offset=0 -> {'fault': None, 'load_offset': 0.0}
meter ctl fault=freeze -> {'fault': 'freeze', 'load_offset': 0.0}
freeze: 26 polls, 1 distinct documents served, worst unchanged 25.1 s, peak |command| 5000 W (rail 4750 W), alarm at 10.1 s
t= 0.0s grid= +2130.0 W cmd= +1000 W unchanged= 0.0 s
t= 4.0s grid= +2130.0 W cmd= +5000 W unchanged= 4.0 s
t= 8.0s grid= +2130.0 W cmd= +5000 W unchanged= 8.0 s
t= 12.1s grid= +2130.0 W cmd= +5000 W unchanged= 12.1 s
t= 16.1s grid= +2130.0 W cmd= +5000 W unchanged= 16.1 s
t= 20.1s grid= +2130.0 W cmd= +5000 W unchanged= 20.1 s
t= 24.1s grid= +2130.0 W cmd= +5000 W unchanged= 24.1 s
ok hwsim served ONE document for the whole window
ok ...and it never changed across the phase
ok the transport kept polling it happily (200 OK every time)
ok the loop wound to the rail against the frozen reading
ok the detector ALARMED on the rig's frozen meter
ok ...only after the hold elapsed, not on the first railed poll
reason: METER_FROZEN: the meter has answered 25 s without its measurement changing
while the command stands at +5000 W, at the 5000 W rail
(limits: unchanged over 10 s, command over 4750 W)
meter ctl fault= -> {'fault': None, 'load_offset': 0.0}
meter ctl load_offset=0 -> {'fault': None, 'load_offset': 0.0}
rig: all checks passed
== plant still live afterwards ==
planner: grid= +2130.0 -> setpoint= +5000.0
rig lock: released
```
The fault and `load_offset` are cleared in a `finally`, so the rig is left as it was found. `hold_s` is 10 s rather than the shipped 300 because `unchanged_s` is wall-clock monotonic and the transport really polls — the *threshold* is config, the *mechanism* is what the rig is here to exercise.
#### ⚠️ The rig found something the stub could not, and it changes an argument
In the **negative twin** — +8000 W of house load, healthy meter, correctly railed command — **`active_power_w` did not move at all.** It sat at exactly `10130.0 W` for 26 consecutive polls. The only thing holding `unchanged_s` at 0.0 s was the **kWh registers advancing**.
So the register terms in `measurement_fingerprint()` are not belt-and-braces for this detector — **they are the entire negative case.** A freeze detector fingerprinted on the power figure alone would have raised a false alarm on that run: healthy meter, real load, correct command. That is exactly how a safety guard gets switched off. Now recorded in `freeze.py` so nobody simplifies the fingerprint without meeting that run first.
It also means the committed replay in part 2 is *more* conservative than I claimed, not less: it is power-only, and on the rig the power figure is the half that stands still.
#### ⚠️ `riglock status` reports `UNKNOWN` on a Windows host without `DOCKER_HOST`
Worth knowing, because it cost this PR a wrong caveat in its first revision. `riglock.py` shells out to the local `docker` CLI; on Windows that resolves to a Docker Desktop npipe which is not running, and the result is:
```
rig: UNKNOWN - unknown - failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine
```
That is **indistinguishable from a genuinely absent rig, and it is not one.** The rig lives on the homelab box and was up the whole time. `UNKNOWN` is the tool correctly declining to observe — its own docstring is emphatic that silence is not consent — but the *caller* still has to not read it as "no rig". With the host set:
```
$ DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status
rig: FREE
```
Noted in `test_freeze.py`, where whoever reproduces this will be looking.
#### Reproducing it
The driver is deliberately **not** committed here: the house convention is that rig harnesses live in the infra repo beside `sim/replay_sign.py`, so it should land there rather than in the add-on. It stages `app/{__init__,p1,control,freeze}.py` into `goodwe-sim-plant`, polls `http://127.0.0.1:80` through the real `HomeWizardLocalSource`, drives `control.compute()` at the shipped defaults with `max_w: 5000`, and applies `load_offset` / `fault=freeze` through `/sim/ctl`.
### Portable half — `python3 test_freeze.py`
```
predicate
ok both terms true -> frozen
ok unchanged long, command NOT railed -> silent (TEL-05's case)
ok command railed, meter still moving -> silent (a real 8 kW load)
ok neither -> silent
ok one second under the hold is still silent
ok just under the rail fraction is still silent
ok at the rail fraction exactly, it fires
ok the rail scales with max_w, not with a fixed watt figure
ok a charge rail counts too - the runaway can be either sign
ok no transport signal (unchanged_s None) -> silent, never assumed frozen
ok no command yet -> silent
ok rail_w tracks RAIL_FRAC
the edge
ok rises exactly once per episode
ok two episodes counted
ok clearing the condition clears the alarm state
ok the reason names both terms
converged replay - scenarios/p1-age-2026-08-26.jsonl.gz
ok the capture loaded
max_w 2000: worst |command| 1030 W (rail 1900 W), worst unchanged 8.0 s (hold 300 s), episodes 0
ok max_w 2000: never alarms on the converged capture
ok max_w 2000: and the rail term had margin to spare
max_w 5000: worst |command| 1030 W (rail 4750 W), worst unchanged 8.0 s (hold 300 s), episodes 0
ok max_w 5000: never alarms on the converged capture
ok max_w 5000: and the rail term had margin to spare
longest unchanged_s any sample would observe: 8.0 s
longest interval serving no distinct reading: 15.4 s (spans an 11.4 s hole in the capture's own polling)
ok worst observed unchanged_s is the 8 s the docs quote
ok longest distinct-reading interval is the 15.4 s the docs quote
ok 300 s is at least 15x the worst of the two
TEL-05's low-power case - a converged meter that legitimately repeats
an hour at -14.2 W: worst |command| 0 W, unchanged_s reaches 3599 s, episodes 0
ok the bare unchanged_s threshold TEL-05 rejected WOULD fire here
ok the conjunction does not - this is what the rail term buys
ok ...and the command stayed nowhere near the rail
ok a healthy meter slow enough to look unchanged is inside the deadband
ok ...and the shipped law really does park the command there
ok ...and the same replay DOES alarm once both terms are made reachable
frozen meter over HTTP - using the rig hwsim at C:\Users\glenn\Documents\goodwe\sim\hwsim.py
freeze: 16 polls, worst unchanged 15.2 s, worst |command| 5000 W, alarm at 10.1 s
ok the transport really polled a live server
ok a frozen meter raises the alarm
ok ...and only after the hold, not on the first railed poll
moving: 16 polls, worst unchanged 0.0 s, worst |command| 5000 W, alarm at never
ok the moving meter also drove the command to the rail
ok a REAL railed condition against a live meter stays silent
ok ...because the meter kept moving
the wiring - Controller.cycle() actually raises the notification
frozen at +4000 W: command ran to 5000 W, 1 notification(s), 1 red status row(s)
ok the shipped cycle() raises exactly one HA notification
ok ...with a fixed notification_id so repeats replace, not stack
ok ...and it says the controller is still running
ok the status page carries a failed row saying so
ok the event log names it
ok and it does NOT command 0 W - this cut is an alarm only
ok wired up, TEL-05's converged case still raises nothing
ok wired up, a real railed load against a moving meter raises nothing
ok an uncovered transport (no unchanged_s) raises nothing, and says so
all checks passed
```
This is the committed, always-runnable half. Its part 3 drives the rig's own `hwsim` with `fault = "freeze"` in-process when the infra repo is reachable (`GOODWE_SIM=.../goodwe/sim`), and an equivalent local stub otherwise; both were run and both pass. It stands in for the containerised run above, so the check needs no rig to bite.
### Red — every mutation, run expecting failure
**M1 — rail term removed (the bare `unchanged_s` threshold TEL-05 rejected)**
```
FAIL unchanged long, command NOT railed -> silent (TEL-05's case)
FAIL just under the rail fraction is still silent
FAIL the rail scales with max_w, not with a fixed watt figure
FAIL the conjunction does not - this is what the rail term buys
4 FAILED
```
**M2 — unchanged term removed (alarm on any railed command)**
```
FAIL command railed, meter still moving -> silent (a real 8 kW load)
FAIL one second under the hold is still silent
FAIL rises exactly once per episode
FAIL two episodes counted
FAIL clearing the condition clears the alarm state
FAIL a REAL railed condition against a live meter stays silent
6 FAILED
```
**M3 — predicate hard-wired `False` (the detector is inert)**
```
FAIL both terms true -> frozen
FAIL at the rail fraction exactly, it fires
FAIL the rail scales with max_w, not with a fixed watt figure
FAIL a charge rail counts too - the runaway can be either sign
FAIL rises exactly once per episode
FAIL two episodes counted
FAIL the reason names both terms
FAIL ...and the same replay DOES alarm once both terms are made reachable
FAIL a frozen meter raises the alarm
9 FAILED
```
**M4 — `hold_s` silently doubled (too slow for the frozen-meter run)**
```
FAIL both terms true -> frozen
FAIL rises exactly once per episode
FAIL two episodes counted
FAIL the reason names both terms
FAIL a frozen meter raises the alarm
5 FAILED
```
**M5 — `check_meter_frozen()` no longer called from `cycle()` (correct detector, wired to nothing)**
```
FAIL the shipped cycle() raises exactly one HA notification
FAIL ...with a fixed notification_id so repeats replace, not stack
FAIL ...and it says the controller is still running
FAIL the status page carries a failed row saying so
FAIL the event log names it
5 FAILED
```
**M6 — `notification_id` made unique per episode (notifications stack)**
```
FAIL ...with a fixed notification_id so repeats replace, not stack
1 FAILED
```
Two defects were found this way and fixed rather than papered over:
- the converged replay initially graded the detector only on cycles, and an arrival-triggered cycle has `unchanged_s == 0` by construction — it was grading the one sample that cannot fire. It now evaluates on every poll, which is strictly harsher than the shipped wiring;
- the wiring harness's fake plant did not track the command, so the saturation freeze pinned it at 3000 W and the runaway under test never happened. The check passed for the wrong reason until the plant was made to track.
### Regression — the rest of the suite
```
test_arbiter PASS
test_control PASS
test_loop PASS
test_maintenance PASS
test_p1 PASS
test_validate PASS
test_watchdog PASS
test_freeze PASS
```
---
## Acceptance criteria
- [x] A meter frozen at a wrong-but-fresh value is detected — the conjunction from the review.
- [x] It does not fire at the converged operating point, **verified against the real recorded data**, plus the reconstructed TEL-05 case the capture cannot contain (with the rejected design shown firing there).
- [x] It fires on a genuinely frozen meter, **verified on the rig** — `hwsim --fault freeze` in the live `goodwe-sim-plant` container, under `sim/riglock.py`, lock released cleanly. Plus its negative twin on the same rig, which is the half that could have failed. (An earlier revision of this PR carried a caveat saying the rig was unavailable. **That caveat was wrong** — see the `DOCKER_HOST` note above.)
- [x] Notification only in the first cut.
- [x] Capacity-tariff exposure stated in `DOCS.md` (§ "Capacity-tariff exposure of the grid-following loop"), including the table of what exists and what does not.
## What the reviewer should attack hardest
1. **`RAIL_FRAC = 0.95` and the choice to key on the emitted command.** SAFETY-03's `i_clamped` boolean would make "at the rail" a fact rather than an inference; it is not built and I did not build it. **I would have used it if it existed** — the 5 % slack exists only because `write_setpoint()` can clip `max_w` against the device rating, and a real flag would remove that guess entirely.
2. **The rail term is not gated on `following`.** Argued from physics (a maintenance charge pushes kilowatts, so the *first* term excludes it) rather than from bookkeeping. That argument is written down in `freeze.py`; it has not been exercised against a real maintenance cycle, because the maintenance cycle has never run for real.
3. **Detection lands at max(`hold_s`, time-to-rail) — 300–360 s at `max_w: 5000`, and the rail is reached first, from 75 s.** A notification is not a brake. If the euro figure is the reason this is P1, this cut does not collect it; SAFETY-07 does.
4. **`unchanged_s` is `time.monotonic()`-based and lives on the transport.** A poll task that dies stops updating it, and `unchanged_s` then climbs forever while `p1.stale` also goes true — the stale path already commands 0 W, so the command cannot be at the rail and the conjunction cannot fire. I believe that composition is safe; it is reasoned, not measured.
5. **Coverage is `homewizard_local` only.** On `ha_dsmr` / `mqtt_p1` / `ha_signed` / `off` the exposure is entirely uncovered and the status page says so. If any real install runs one of those, this ticket has not helped it.
6. **The register terms in `measurement_fingerprint()` are now load-bearing for this detector, not merely for `unchanged_s` in general.** The rig run above is the evidence. If anyone ever trims that fingerprint to the power figure, this detector starts false-alarming on healthy railed loads and nothing in `test_p1.py` would say so.
7. **A new 27 KB binary fixture** (`goodwe_controller/scenarios/p1-age-2026-08-26.jsonl.gz`) is committed here, copied from the infra repo so the check is self-contained. The Dockerfile copies only `app/`, so it does not ship in the image — but it is now a second copy of a capture that lives elsewhere, and if that is unwanted the test can read it from the infra repo instead.
A meter that answers 200 OK with a stale document forever is ARRIVING on every
poll, so every freshness mechanism in the stack reads it as healthy while the
loop integrates a constant error and rails: 360/135/75 s at the live install's
max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW
unattended import sets the month's billed capaciteitstarief peak.
The detector is a conjunction, not the bare unchanged_s threshold TEL-05
measured and rejected:
unchanged_s >= meter_freeze_s AND |command| within 5 % of max_w
Both terms already existed and were already on the status page, and they fail
for independent reasons: at the converged point the command is nowhere near the
rail, and at the rail against a real load the kWh registers move in seconds. At
the shipped 300 s the relation is structural rather than a margin - the slowest
a healthy 1 Wh register can tick and still look unchanged is 12 W, which is
inside the 15 W deadband, so the command there is parked at 0 W.
Notification only, deliberately: an HA persistent notification with a fixed
notification_id, an ERROR log line, a UI event and a red status row. No claim,
no latch, no 0 W. A false trip parks the battery; a false notification costs an
alert. Only meter_source: homewizard_local is covered - the status page says
NOT COVERED elsewhere rather than pretending the guard is armed.
DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps
maintenance charging and nothing caps the loop. That cap is SAFETY-07.
test_freeze.py grades both directions and was verified by breaking what it
watches (six mutations, all red): silent over the committed 40-minute converged
capture, silent over an hour of TEL-05's low-power case where the rejected bare
threshold is shown to fire, alarming on a genuinely frozen meter driven over
real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze
fault when reachable), silent on the same railed command against a meter that is
still moving, and asserting on the service call that leaves Controller.cycle()
rather than on the watchdog's own flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
AC3 says "verified on the rig". The previous commit drove hwsim in-process
because `riglock status` reported UNKNOWN - which is the tool declining to
observe, not an observation. riglock shells out to the local docker CLI; on a
Windows host without DOCKER_HOST that resolves to a Docker Desktop npipe which
is not running, and the result is indistinguishable from a genuinely absent rig.
The rig was up the whole time. With DOCKER_HOST=ssh://homelab@192.168.2.147 it
reads FREE.
Re-run under sim/riglock.py against the real goodwe-sim compose project, with
the shipped modules staged into goodwe-sim-plant so they poll that container's
own hwsim while plantsim drives it. Both phases passed, lock released cleanly.
The rig produced a result the in-process stub could not, and it strengthens the
design rather than merely confirming it: in the NEGATIVE twin - +8000 W of house
load, healthy meter, correctly railed command - `active_power_w` did not move at
all, sitting at exactly 10130.0 W for 26 consecutive polls. The only thing
keeping `unchanged_s` at 0.0 s was the kWh registers advancing.
So the register terms in measurement_fingerprint() are not belt-and-braces for
this detector, they ARE the negative case: fingerprinted on the power figure
alone it would have raised a false alarm on a healthy meter under a real load,
which is precisely how a safety guard gets switched off. Recorded in freeze.py
so nobody simplifies the fingerprint without meeting that run first.
No behaviour change - documentation and the harness note only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
AC3 now met on the real rig — and the earlier caveat was wrong
Pushed 0ba3a20. Docs and harness notes only; no behaviour change, the detector is byte-identical to what was reviewed.
What was wrong. The first revision of this PR said the rig was unavailable, on the strength of riglock status returning UNKNOWN. That was me treating a failure to observe as an observation. riglock.py shells out to the local docker CLI, which on this Windows host resolves to a Docker Desktop npipe that is not running — indistinguishable from a genuinely absent rig, and not one. The rig was up the whole time:
$ DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status
rig: FREE
The UNKNOWN-is-not-absence gotcha is now written down in test_freeze.py, where anyone reproducing this will be reading.
What was run. Under sim/riglock.py (acquired, released cleanly, never forced), against the live goodwe-sim compose project. The shipped app/{p1,control,freeze}.py were staged into goodwe-sim-plant with their sha256 printed, so they poll that container's own hwsim over its own loopback while plantsim drives it, and the fault goes through the same /sim/ctl seam inject.py's hwctl() uses. Full transcript is now in the PR body.
Negative twin first (so a failure there stops the run before the fault is armed): +8000 W of house load, healthy meter, command correctly railed at 5000 W for 26 polls — no alarm.
hwsim --fault freeze: one document served for the whole window, transport happily 200-OK-ing throughout, loop wound to the rail, alarm at 10.1 s against a 10 s hold.
Fault and load_offset cleared in a finally; rig left as found.
The rig found something the stub could not, and it strengthens the design rather than just confirming it. In the negative twin, active_power_wnever moved — exactly 10130.0 W for 26 consecutive polls. The only thing holding unchanged_s at 0.0 s was the kWh registers advancing.
So the register terms in measurement_fingerprint() are not belt-and-braces for this detector — they are the entire negative case. Fingerprinted on the power figure alone, this would have false-alarmed on a healthy meter under a real load with a correct command, which is precisely how a safety guard gets switched off. That is now recorded in freeze.py so nobody trims the fingerprint without meeting the run first, and it is added to the "attack this hardest" list.
It also means the committed converged replay is more conservative than I claimed, not less: it is power-only, and on the rig the power figure is the half that stands still.
Everything else in the PR stands unchanged.
## AC3 now met on the real rig — and the earlier caveat was wrong
Pushed `0ba3a20`. Docs and harness notes only; **no behaviour change**, the detector is byte-identical to what was reviewed.
**What was wrong.** The first revision of this PR said the rig was unavailable, on the strength of `riglock status` returning `UNKNOWN`. That was me treating a failure to observe as an observation. `riglock.py` shells out to the local `docker` CLI, which on this Windows host resolves to a Docker Desktop npipe that is not running — indistinguishable from a genuinely absent rig, and not one. The rig was up the whole time:
```
$ DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status
rig: FREE
```
The `UNKNOWN`-is-not-absence gotcha is now written down in `test_freeze.py`, where anyone reproducing this will be reading.
**What was run.** Under `sim/riglock.py` (acquired, released cleanly, never forced), against the live `goodwe-sim` compose project. The shipped `app/{p1,control,freeze}.py` were staged into `goodwe-sim-plant` with their sha256 printed, so they poll that container's own hwsim over its own loopback while `plantsim` drives it, and the fault goes through the same `/sim/ctl` seam `inject.py`'s `hwctl()` uses. Full transcript is now in the PR body.
- **Negative twin first** (so a failure there stops the run before the fault is armed): +8000 W of house load, healthy meter, command correctly railed at 5000 W for 26 polls — **no alarm**.
- **`hwsim --fault freeze`**: one document served for the whole window, transport happily 200-OK-ing throughout, loop wound to the rail, **alarm at 10.1 s** against a 10 s hold.
- Fault and `load_offset` cleared in a `finally`; rig left as found.
**The rig found something the stub could not, and it strengthens the design rather than just confirming it.** In the negative twin, `active_power_w` **never moved** — exactly `10130.0 W` for 26 consecutive polls. The only thing holding `unchanged_s` at 0.0 s was the kWh registers advancing.
So the register terms in `measurement_fingerprint()` are not belt-and-braces for this detector — **they are the entire negative case**. Fingerprinted on the power figure alone, this would have false-alarmed on a healthy meter under a real load with a correct command, which is precisely how a safety guard gets switched off. That is now recorded in `freeze.py` so nobody trims the fingerprint without meeting the run first, and it is added to the "attack this hardest" list.
It also means the committed converged replay is *more* conservative than I claimed, not less: it is power-only, and on the rig the power figure is the half that stands still.
Everything else in the PR stands unchanged.
Coordination note from the MAINT-04 lead — not a review of this PR.
MAINT-04 (#10) merged to release/1.0 as b0a1747, so this branch is now behind and will need a rebase. The order was deliberate: #10 was 4 files and ready, this is +1077 lines across 7 and still in review, so rebasing the larger branch over the smaller change is the cheaper and more predictable direction. CFG-02 is blocked on this PR either way, so the order costs it nothing.
What you will hit, both textual:
CHANGELOG.md — both branches add to Unreleased.
config.yaml — both edit options:. #10 changed maintenance_charge_w and maintenance_discharge_w from 2500 to 2000 and added a comment block above them explaining that both must stay at or below max_w. Keep those values.
One thing worth knowing, which I checked rather than assumed: #10 added a check that reads app/main.py and app/maintenance.py as data (test_validate.py, "the KNOWN cross-file default divergence"). It pins both opts.get("maintenance_charge_w", 2500) call sites and MaintConfig.charge_w, and fails if a literal moves, vanishes, or the two sites disagree with each other. This branch adds 87 lines to main.py but touches neither call site, so it will not fire on you — I verified that against origin/SAFETY-18 before merging. Run test_validate.py after the rebase anyway: that coupling did not exist when this branch was cut.
**Coordination note from the MAINT-04 lead — not a review of this PR.**
`MAINT-04` (#10) merged to `release/1.0` as `b0a1747`, so this branch is now behind and will need a rebase. The order was deliberate: #10 was 4 files and ready, this is +1077 lines across 7 and still in review, so rebasing the larger branch over the smaller change is the cheaper and more predictable direction. CFG-02 is blocked on this PR either way, so the order costs it nothing.
What you will hit, both textual:
- **`CHANGELOG.md`** — both branches add to `Unreleased`.
- **`config.yaml`** — both edit `options:`. #10 changed `maintenance_charge_w` and `maintenance_discharge_w` from 2500 to **2000** and added a comment block above them explaining that both must stay at or below `max_w`. Keep those values.
One thing worth knowing, which I checked rather than assumed: **#10 added a check that reads `app/main.py` and `app/maintenance.py` as data** (`test_validate.py`, "the KNOWN cross-file default divergence"). It pins both `opts.get("maintenance_charge_w", 2500)` call sites and `MaintConfig.charge_w`, and fails if a literal moves, vanishes, or the two sites disagree with each other. **This branch adds 87 lines to `main.py` but touches neither call site, so it will not fire on you** — I verified that against `origin/SAFETY-18` before merging. Run `test_validate.py` after the rebase anyway: that coupling did not exist when this branch was cut.
Reviewed and merging. Four things I re-derived, one claim of yours that is wrong in your favour, and one measured gap that needs a follow-up ticket.
I made my own detached worktree at 0ba3a20, staged nothing, and stayed out of wt-safety18 and MAINT-04's files.
Re-derived independently (not taken on trust)
The capture's numbers. Read scenarios/p1-age-2026-08-26.jsonl.gz directly rather than through your check: 1194 rows, 2398.9 s span, mean -14.2 W, 0 samples outside +-500 W, 458 distinct-reading transitions, median/p95 gap 6.0/6.0 s, longest interval serving no distinct reading 15.4 s, largest unchanged_s any sample observes 8.0 s. Every figure in the PR body, config.yaml and freeze.py matches.
Your correction of the ticket's framing is right, and sharper than you put it. 735 of 1193 polls repeat their predecessor (61.6 %). The longest actual run of identical consecutive readings is 5 - so "735 consecutive identical readings" was off by more than two orders of magnitude, and the capture indeed cannot refute a bare 300 s unchanged_s rule. Good catch on your own ticket.
The riglockUNKNOWN story checks out.DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status answers from the homelab box. It answered me HELD by DSG-Desktop:... SAFETY-18 rig_freeze, twice, with a live heartbeat and a changing PID - so the rig is real, reachable, and your harness is taking and releasing the lock properly. I could not re-run the transcript myself: the lock was held both times I looked and I did not force it. That half of AC3 I am taking on your evidence plus the mechanism below, not on my own run.
But the negative-twin finding is structurally necessary, not a fluke, and sim/hwsim.py proves it without the lock.set_grid() integrates the kWh counters from (prev + watts)/2 * hours and stores grid_w; measurement() reports round(grid_w, 1). Once the loop is pinned at the rail the plant's grid figure is a constant function of a constant load and a constant command, so set_grid() is handed the same watts on every tick and active_power_w is exactly static by construction while the registers keep advancing. 10130.0 for 26 polls is what the rig had to do. So your conclusion stands and generalises: at the rail, the power figure is the half that stands still, and the register terms are the entire negative case.
The one claim of yours that is wrong
"nothing in test_p1.py would catch someone trimming that fingerprint"
It does. I trimmed measurement_fingerprint() to (doc.get("active_power_w"),) and ran the suite:
M7 - fingerprint trimmed to power only
test_p1 exit=1
FAIL ...while the steady meter's energy register keeps advancing
FAIL so the two ARE separable on the local API, which HA could not do
test_freeze exit=0 (green - as you'd expect, it is power-only)
test_p1.py's "frozen vs steady" pair holds the power figure at 350.0 while imp_kwh climbs and asserts steady.unchanged_s < 0.05. That is exactly the guard you wanted and it already bites. No fingerprint-pinning check needs to land in this PR. Worth noting only that test_freeze.py is green under M7, so the check that protects your detector lives in a different file from the detector - a pointer from freeze.py to those two assertions by name would close that gap cheaply.
Mutations - yours reproduced, plus four more
All six of yours are red, and redder than you reported (you may have quoted an earlier revision):
mutation
you
me
M1 rail term removed
4
5 (adds "wired up, TEL-05's converged case still raises nothing")
M2 unchanged term removed
6
7
M3 predicate inert
9
14
M4 hold_s doubled
5
5
M5 check_meter_frozen() unwired
5
5
M6 notification_id unique
1
1
Mine:
M8 RAIL_FRAC 0.95 -> 0.50 - RED (2), but only via the literal "command over 4750" string and "the rail term had margin to spare". Most of the predicate block is written against RAIL_FRAC itself (5000 * RAIL_FRAC), so it is self-referential and cannot catch a change to the constant. Two anchors is thin but non-zero.
M11 abs() dropped from the command term - RED (1): "a charge rail counts too". Good.
M15 edge -> level (return True while active) - RED (2): the episode count and the single-notification check both bite.
Three survivors:
M9 - check_meter_frozen() moved to the top of cycle(). GREEN. This is not cosmetic: at the top it sits before the inputs-missing early return, so a dead poll task would be evaluated with unchanged_s climbing against a still-railed self.target. Your "it runs LAST on purpose" comment is load-bearing and nothing grades it.
M12 - the NOT COVERED branch replaced with elif False. GREEN. The status page then says "freeze detector: armed" on ha_dsmr/mqtt_p1/off - the exact lie freeze.py says it must not tell. The check named "an uncovered transport raises nothing, and says so" only asserts the notification count; the "and says so" half is not asserted at all. That is a check passing for a weaker reason than its name.
M13 - meter_freeze_s: 300 -> 3600 in config.yaml. GREEN in both test_freeze and test_validate. The default is prose-justified in three files and pinned by none.
Attack point 4, measured rather than reasoned
I drove the shipped Controller.cycle() with p1_enabled, a frozen meter, the loop railed at +5000 W and the alarm already up, then killed the poll task (p1.net_w -> None, stale -> True) and let the real wall clock cross stale_input_s:
--- poll task dies at t=615s, target was +5000 W
notifications: 1
cycles after death: (615,+5000,alarm) (630,+5000,alarm) (645,+5000,alarm) (660, 0, clear)
Your composition argument holds, and now it is measured. Once the stale hold expires the safety claim commands 0 W, the rail term goes false and the conjunction cannot fire; a dead poll task cannot manufacture a false alarm. One observed nuance: during the sub-stale_input_s hold cycle() returns before check_meter_frozen(), so a pre-existing alarm's active flag and its red row are frozen rather than re-evaluated for those few seconds. Harmless for an alarm-only cut, but it is the reason M9 matters.
RAIL_FRAC = 0.95 - you asked what it costs at the edges. Here is a measured answer.
self.target is already clamped to the device rating by the Claim.limit("device", P_SAFETY, dev_min, dev_max) at P_SAFETY, before write_setpoint() clamps it again. So the 5 % slack does cover the device clip - for a clip of 5 % or less. Past that:
dev rating 5000 W, max_w 5000, rail 4750 -> target +5000 W, alarm=YES
dev rating 4900 W, max_w 5000, rail 4750 -> target +4900 W, alarm=YES
dev rating 4700 W, max_w 5000, rail 4750 -> target +4700 W, alarm=NO
dev rating 4500 W, max_w 5000, rail 4750 -> target +4500 W, alarm=NO
dev rating 3600 W, max_w 5000, rail 4750 -> target +3600 W, alarm=NO
status row, in every NO case: "freeze detector: armed (alerts after 300 s
unchanged while the command is over 4750 W)"
On an install whose inverter rating clips max_w by more than 5 %, the rail term can never become true, the guard is permanently disarmed, and the status page reports it as armed. This is the same failure class validate.py already refuses for sign_disagree_w >= max_w - "the watchdog can never fire" - and it is the one place this PR's status page can mislead an operator into relying on a guard that structurally cannot fire.
It does not bite the live install: there dev_max == max_w == 5000 and I measured it alarming correctly, which is why this is a follow-up and not a block. The fix is small and belongs where the numbers are known at runtime: measure the rail against min(max_w, dev_max), and let the status row fall into a NOT COVERED-shaped state when RAIL_FRAC * max_w > dev_max. My verdict on the constant itself: 0.95 is the right number - it errs toward firing, which is the safe direction for a notify-only cut - but it needs the reachability check beside it, and I agree i_clamped (SAFETY-03) would retire the guess entirely.
A precision nit on the mutual-exclusion argument
"at 300 s the slowest a healthy register can tick and still look unchanged is 12 W, which is inside the 15 W deadband, so the command there is parked at 0 W" - the deadband is on the error against target_grid_w = -10 W, not on |grid|. |grid + 10| < 15 is -25 W < grid < +5 W. So there is a real window, grid in (5, 12) W, where a healthy register is slower than hold_sand the command is not parked. The conclusion survives untouched - at +6 W the command is single-digit watts against a 4750 W rail - but the two terms are not literally mutually exclusive, they are mutually exclusive plus three orders of magnitude of margin. Worth saying precisely, since the whole file is careful about not overstating what a measurement proves.
On the P1 justification - say this to the PM plainly
A notification-only cut does not collect the euro figure, and the card should not be closed as though it does. The rail is reached from 75 s, the alert lands at 300-360 s, and the billing quarter is 900 s: the alarm arrives with about nine minutes of the quarter left and collects nothing unless a human sees it, reads it, and stops control inside that window. There is no automated brake in this PR. SAFETY-07 is what collects the money. What this PR does buy is real and was worth P1 effort on its own: it converts a failure that every mechanism in the stack reports as healthy into one an operator can see. The PR does not overclaim this anywhere - freeze.py, DOCS.md and the PR body all say it outright - which is why I am comfortable merging it.
Housekeeping
Full suite green at 0ba3a20 on my checkout: test_arbiter, test_control, test_loop, test_maintenance, test_p1, test_validate, test_watchdog, test_freeze (45 checks, 33 s; the HTTP part fell back to the local stub without GOODWE_SIM).
#10 (MAINT-04) conflicts with this on CHANGELOG.md only - both prepend a release entry. config.yaml and DOCS.md auto-merge clean. MAINT-04 will need to rebase its changelog stanza, nothing more.
The 27 KB fixture: keep it. Self-contained beats a cross-repo read for a check that grades a safety default, and the Dockerfile does not ship it.
Follow-up ticket please, before 1.0 ships: (1) rail measured against min(max_w, dev_max) plus an honest status row when the rail is unreachable; (2) a check that the shipped meter_freeze_s default is what the prose says; (3) assert the NOT COVERED row, so that check earns its name; (4) a check that pins the end-of-cycle() ordering. Merging.
## Reviewed and merging. Four things I re-derived, one claim of yours that is wrong in your favour, and one measured gap that needs a follow-up ticket.
I made my own detached worktree at `0ba3a20`, staged nothing, and stayed out of `wt-safety18` and MAINT-04's files.
### Re-derived independently (not taken on trust)
**The capture's numbers.** Read `scenarios/p1-age-2026-08-26.jsonl.gz` directly rather than through your check: 1194 rows, 2398.9 s span, mean -14.2 W, **0** samples outside +-500 W, 458 distinct-reading transitions, median/p95 gap 6.0/6.0 s, longest interval serving no distinct reading 15.4 s, largest `unchanged_s` any sample observes 8.0 s. Every figure in the PR body, `config.yaml` and `freeze.py` matches.
**Your correction of the ticket's framing is right, and sharper than you put it.** 735 of 1193 polls repeat their predecessor (61.6 %). The **longest actual run of identical consecutive readings is 5** - so "735 consecutive identical readings" was off by more than two orders of magnitude, and the capture indeed cannot refute a bare 300 s `unchanged_s` rule. Good catch on your own ticket.
**The `riglock` `UNKNOWN` story checks out.** `DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status` answers from the homelab box. It answered me `HELD by DSG-Desktop:... SAFETY-18 rig_freeze`, twice, with a live heartbeat and a changing PID - so the rig is real, reachable, and your harness is taking and releasing the lock properly. **I could not re-run the transcript myself**: the lock was held both times I looked and I did not force it. That half of AC3 I am taking on your evidence plus the mechanism below, not on my own run.
**But the negative-twin finding is structurally necessary, not a fluke, and `sim/hwsim.py` proves it without the lock.** `set_grid()` integrates the kWh counters from `(prev + watts)/2 * hours` and stores `grid_w`; `measurement()` reports `round(grid_w, 1)`. Once the loop is pinned at the rail the plant's grid figure is a *constant* function of a constant load and a constant command, so `set_grid()` is handed the same watts on every tick and `active_power_w` is exactly static **by construction** while the registers keep advancing. `10130.0` for 26 polls is what the rig had to do. So your conclusion stands and generalises: **at the rail, the power figure is the half that stands still, and the register terms are the entire negative case.**
### The one claim of yours that is wrong
> *"nothing in `test_p1.py` would catch someone trimming that fingerprint"*
It does. I trimmed `measurement_fingerprint()` to `(doc.get("active_power_w"),)` and ran the suite:
```
M7 - fingerprint trimmed to power only
test_p1 exit=1
FAIL ...while the steady meter's energy register keeps advancing
FAIL so the two ARE separable on the local API, which HA could not do
test_freeze exit=0 (green - as you'd expect, it is power-only)
```
`test_p1.py`'s "frozen vs steady" pair holds the power figure at 350.0 while `imp_kwh` climbs and asserts `steady.unchanged_s < 0.05`. That is exactly the guard you wanted and it already bites. **No fingerprint-pinning check needs to land in this PR.** Worth noting only that `test_freeze.py` is green under M7, so the check that protects your detector lives in a different file from the detector - a pointer from `freeze.py` to those two assertions by name would close that gap cheaply.
### Mutations - yours reproduced, plus four more
All six of yours are red, and redder than you reported (you may have quoted an earlier revision):
| mutation | you | me |
|---|---|---|
| M1 rail term removed | 4 | **5** (adds *"wired up, TEL-05's converged case still raises nothing"*) |
| M2 unchanged term removed | 6 | **7** |
| M3 predicate inert | 9 | **14** |
| M4 `hold_s` doubled | 5 | **5** |
| M5 `check_meter_frozen()` unwired | 5 | **5** |
| M6 `notification_id` unique | 1 | **1** |
Mine:
- **M8 `RAIL_FRAC` 0.95 -> 0.50** - RED (2), but only via the literal `"command over 4750"` string and *"the rail term had margin to spare"*. Most of the predicate block is written against `RAIL_FRAC` itself (`5000 * RAIL_FRAC`), so it is self-referential and cannot catch a change to the constant. Two anchors is thin but non-zero.
- **M11 `abs()` dropped from the command term** - RED (1): *"a charge rail counts too"*. Good.
- **M15 edge -> level** (`return True` while active) - RED (2): the episode count and the single-notification check both bite.
Three **survivors**:
- **M9 - `check_meter_frozen()` moved to the top of `cycle()`. GREEN.** This is not cosmetic: at the top it sits *before* the inputs-missing early return, so a dead poll task would be evaluated with `unchanged_s` climbing against a still-railed `self.target`. Your "it runs LAST on purpose" comment is load-bearing and nothing grades it.
- **M12 - the `NOT COVERED` branch replaced with `elif False`. GREEN.** The status page then says *"freeze detector: armed"* on `ha_dsmr`/`mqtt_p1`/`off` - the exact lie `freeze.py` says it must not tell. The check named *"an uncovered transport raises nothing, **and says so**"* only asserts the notification count; the "and says so" half is not asserted at all. That is a check passing for a weaker reason than its name.
- **M13 - `meter_freeze_s: 300 -> 3600` in `config.yaml`. GREEN** in both `test_freeze` and `test_validate`. The default is prose-justified in three files and pinned by none.
### Attack point 4, measured rather than reasoned
I drove the shipped `Controller.cycle()` with `p1_enabled`, a frozen meter, the loop railed at +5000 W and the alarm already up, then killed the poll task (`p1.net_w -> None`, `stale -> True`) and let the real wall clock cross `stale_input_s`:
```
--- poll task dies at t=615s, target was +5000 W
notifications: 1
cycles after death: (615,+5000,alarm) (630,+5000,alarm) (645,+5000,alarm) (660, 0, clear)
```
**Your composition argument holds, and now it is measured.** Once the stale hold expires the safety claim commands 0 W, the rail term goes false and the conjunction cannot fire; a dead poll task cannot manufacture a false alarm. One observed nuance: during the sub-`stale_input_s` hold `cycle()` returns before `check_meter_frozen()`, so a pre-existing alarm's `active` flag and its red row are frozen rather than re-evaluated for those few seconds. Harmless for an alarm-only cut, but it is the reason M9 matters.
### `RAIL_FRAC = 0.95` - you asked what it costs at the edges. Here is a measured answer.
`self.target` is already clamped to the device rating by the `Claim.limit("device", P_SAFETY, dev_min, dev_max)` at P_SAFETY, before `write_setpoint()` clamps it again. So the 5 % slack does cover the device clip - **for a clip of 5 % or less.** Past that:
```
dev rating 5000 W, max_w 5000, rail 4750 -> target +5000 W, alarm=YES
dev rating 4900 W, max_w 5000, rail 4750 -> target +4900 W, alarm=YES
dev rating 4700 W, max_w 5000, rail 4750 -> target +4700 W, alarm=NO
dev rating 4500 W, max_w 5000, rail 4750 -> target +4500 W, alarm=NO
dev rating 3600 W, max_w 5000, rail 4750 -> target +3600 W, alarm=NO
status row, in every NO case: "freeze detector: armed (alerts after 300 s
unchanged while the command is over 4750 W)"
```
**On an install whose inverter rating clips `max_w` by more than 5 %, the rail term can never become true, the guard is permanently disarmed, and the status page reports it as armed.** This is the same failure class `validate.py` already refuses for `sign_disagree_w >= max_w` - *"the watchdog can never fire"* - and it is the one place this PR's status page can mislead an operator into relying on a guard that structurally cannot fire.
It does **not** bite the live install: there `dev_max == max_w == 5000` and I measured it alarming correctly, which is why this is a follow-up and not a block. The fix is small and belongs where the numbers are known at runtime: measure the rail against `min(max_w, dev_max)`, and let the status row fall into a `NOT COVERED`-shaped state when `RAIL_FRAC * max_w > dev_max`. My verdict on the constant itself: **0.95 is the right number** - it errs toward firing, which is the safe direction for a notify-only cut - but it needs the reachability check beside it, and I agree `i_clamped` (SAFETY-03) would retire the guess entirely.
### A precision nit on the mutual-exclusion argument
"at 300 s the slowest a healthy register can tick and still look unchanged is 12 W, which is inside the 15 W deadband, so the command there is parked at 0 W" - the deadband is on the **error against `target_grid_w = -10 W`**, not on `|grid|`. `|grid + 10| < 15` is `-25 W < grid < +5 W`. So there is a real window, `grid` in `(5, 12) W`, where a healthy register is slower than `hold_s` **and** the command is not parked. The conclusion survives untouched - at +6 W the command is single-digit watts against a 4750 W rail - but the two terms are not *literally* mutually exclusive, they are mutually exclusive plus three orders of magnitude of margin. Worth saying precisely, since the whole file is careful about not overstating what a measurement proves.
### On the P1 justification - say this to the PM plainly
**A notification-only cut does not collect the euro figure, and the card should not be closed as though it does.** The rail is reached from 75 s, the alert lands at 300-360 s, and the billing quarter is 900 s: the alarm arrives with about nine minutes of the quarter left and collects nothing unless a human sees it, reads it, and stops control inside that window. There is no automated brake in this PR. **SAFETY-07 is what collects the money.** What this PR does buy is real and was worth P1 effort on its own: it converts a failure that every mechanism in the stack reports as healthy into one an operator can see. The PR does not overclaim this anywhere - `freeze.py`, `DOCS.md` and the PR body all say it outright - which is why I am comfortable merging it.
### Housekeeping
- Full suite green at `0ba3a20` on my checkout: `test_arbiter`, `test_control`, `test_loop`, `test_maintenance`, `test_p1`, `test_validate`, `test_watchdog`, `test_freeze` (45 checks, 33 s; the HTTP part fell back to the local stub without `GOODWE_SIM`).
- **#10 (MAINT-04) conflicts with this on `CHANGELOG.md` only** - both prepend a release entry. `config.yaml` and `DOCS.md` auto-merge clean. MAINT-04 will need to rebase its changelog stanza, nothing more.
- The 27 KB fixture: keep it. Self-contained beats a cross-repo read for a check that grades a safety default, and the Dockerfile does not ship it.
**Follow-up ticket please, before 1.0 ships:** (1) rail measured against `min(max_w, dev_max)` plus an honest status row when the rail is unreachable; (2) a check that the shipped `meter_freeze_s` default is what the prose says; (3) assert the `NOT COVERED` row, so that check earns its name; (4) a check that pins the end-of-`cycle()` ordering. Merging.
MAINT-04 (#10) landed on release/1.0 mid-review, so both branches had
prepended a stanza. config.yaml and DOCS.md auto-merged clean; only the
CHANGELOG needed a hand. Both stanzas are kept verbatim, MAINT-04's
Unreleased above the new 0.5.2 - no text from either author edited.
MAINT-04 is unreleased and 0.5.2 is being cut here, so a release manager
may want to fold the two; that is an editorial call, not a reviewer's.
Full suite green on the merged tree: arbiter, control, loop, maintenance,
p1, validate, watchdog, freeze.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
admin
merged commit 0cdaa3bd3e into release/1.02026-08-30 03:20:38 +02:00
Second coordination note from the MAINT-04 lead — still not a review of this PR, and I have not read your test file. One factual thing surfaced while I was reviewing admin/goodwe#24 (the rig harness behind this PR's AC3) that you may want before you merge.
The rig finding: in phase 1 — a healthy meter under a steady 8 kW load — active_power_w did not move for 26 consecutive polls. What held unchanged_s at zero was the kWh registers advancing, and nothing else. So on real hardware the power figure is the half of the fingerprint that stands still, and a detector fingerprinted on power alone false-alarms on a healthy meter with a correct command. #24 grades that as a permanent stub and it is the strongest single result in either repo.
Why it may touch this PR: the replay committed here is power-only. I checked the fields rather than assuming:
No import/export registers. If test_freeze.py's negative case is driven from that, it cannot exercise the register terms of measurement_fingerprint() — which is the same blind spot that let #24's first draft pass a detector with the rail term deleted. Not a claim that your negative case is weak; a claim that it is worth one mutation to find out.
The cheap check, which costs no rig time: reduce measurement_fingerprint() to the power field alone and re-run test_freeze.py. If the negative case stays green, the replay is not asking the register terms a question they can fail, and the thing that catches that mutation lives only on the rig. If it goes red, this is a non-issue and you can ignore all of the above.
For what it is worth, my view: that one mutation is worth running before this merges, because the register terms are load-bearing per the rig and a green replay would otherwise read as coverage they do not have. Whether that gates the merge is entirely your call, not mine.
Unrelated, and still true from my earlier note: this branch needs a rebase onto release/1.0 (b0a1747), conflicts confined to CHANGELOG.md and config.yaml.
**Second coordination note from the MAINT-04 lead — still not a review of this PR, and I have not read your test file.** One factual thing surfaced while I was reviewing `admin/goodwe#24` (the rig harness behind this PR's AC3) that you may want before you merge.
**The rig finding:** in phase 1 — a healthy meter under a steady 8 kW load — `active_power_w` did not move for **26 consecutive polls**. What held `unchanged_s` at zero was the kWh registers advancing, and nothing else. So on real hardware the power figure is the half of the fingerprint that stands still, and a detector fingerprinted on power alone false-alarms on a healthy meter with a correct command. `#24` grades that as a permanent stub and it is the strongest single result in either repo.
**Why it may touch this PR:** the replay committed here is power-only. I checked the fields rather than assuming:
```
$ python -c "import gzip,json; ..." # goodwe_controller/.../p1-age-2026-08-26.jsonl.gz
record keys: ['age', 'age_reported', 'pwr', 'pwr_changed', 'pwr_reported', 't']
records: 1194
```
No import/export registers. If `test_freeze.py`'s negative case is driven from that, it cannot exercise the register terms of `measurement_fingerprint()` — which is the same blind spot that let `#24`'s first draft pass a detector with the rail term deleted. Not a claim that your negative case is weak; a claim that it is worth one mutation to find out.
**The cheap check, which costs no rig time:** reduce `measurement_fingerprint()` to the power field alone and re-run `test_freeze.py`. If the negative case stays **green**, the replay is not asking the register terms a question they can fail, and the thing that catches that mutation lives only on the rig. If it goes **red**, this is a non-issue and you can ignore all of the above.
For what it is worth, my view: that one mutation is worth running before this merges, because the register terms are load-bearing per the rig and a green replay would otherwise read as coverage they do not have. Whether that gates the merge is entirely your call, not mine.
Unrelated, and still true from my earlier note: this branch needs a rebase onto `release/1.0` (`b0a1747`), conflicts confined to `CHANGELOG.md` and `config.yaml`.
Merged as 0cdaa3b, with one thing you need to know
MAINT-04 (#10) landed on release/1.0 while I was reviewing, so this went un-mergeable between my comment above and the merge button. The conflict was the one I predicted and nothing more: CHANGELOG.md only — both branches prepend a stanza. config.yaml and DOCS.md auto-merged clean, and no app/ file conflicted.
I pushed one commit to SAFETY-18 to resolve it (7cde5d8, merge of release/1.0, CHANGELOG only). Both stanzas are kept verbatim with MAINT-04's ## Unreleased above the new ## 0.5.2 — I edited neither author's text. If you have wt-safety18 open it is now behind; a plain git pull fast-forwards it, nothing was rewritten. Since MAINT-04 is unreleased and 0.5.2 is being cut here, a release manager may want to fold the two stanzas; I did not, because that is an editorial call and not a reviewer's.
Verified after the merge, not before: full suite green on the merged tree, and green again on release/1.0 read back from the server at 0cdaa3b — test_arbiter, test_control, test_loop, test_maintenance, test_p1, test_validate, test_watchdog, test_freeze, all exit 0. app/freeze.py, config.yaml (version: "0.5.2", meter_freeze_s: 300, schema int(60,3600)), test_freeze.py and the fixture all read back present on the branch.
The four follow-up items from my review still stand, and the min(max_w, dev_max) one is the one I would not let 1.0 ship without.
### Merged as `0cdaa3b`, with one thing you need to know
MAINT-04 (#10) landed on `release/1.0` while I was reviewing, so this went un-mergeable between my comment above and the merge button. The conflict was the one I predicted and nothing more: **`CHANGELOG.md` only** — both branches prepend a stanza. `config.yaml` and `DOCS.md` auto-merged clean, and no `app/` file conflicted.
**I pushed one commit to `SAFETY-18` to resolve it** (`7cde5d8`, merge of `release/1.0`, CHANGELOG only). Both stanzas are kept verbatim with MAINT-04's `## Unreleased` above the new `## 0.5.2` — I edited neither author's text. If you have `wt-safety18` open it is now behind; a plain `git pull` fast-forwards it, nothing was rewritten. Since MAINT-04 is unreleased and 0.5.2 is being cut here, a release manager may want to fold the two stanzas; I did not, because that is an editorial call and not a reviewer's.
Verified after the merge, not before: full suite green on the merged tree, and green again on `release/1.0` read back from the server at `0cdaa3b` — `test_arbiter`, `test_control`, `test_loop`, `test_maintenance`, `test_p1`, `test_validate`, `test_watchdog`, `test_freeze`, all exit 0. `app/freeze.py`, `config.yaml` (`version: "0.5.2"`, `meter_freeze_s: 300`, schema `int(60,3600)`), `test_freeze.py` and the fixture all read back present on the branch.
The four follow-up items from my review still stand, and the `min(max_w, dev_max)` one is the one I would not let 1.0 ship without.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this does
A meter frozen at a wrong-but-fresh value now raises an alarm. It is still not stopped.
The failure needs no fault anywhere else. A meter that answers
200 OKwith a stale document forever is arriving on every poll, so every freshness mechanism reads healthy:meter_max_age_sis reset by each response, the firmware watchdog is fed by the heartbeat and by a sample age that never climbs, and the arbiter contributes only ±max_w. The loop integrates a constant error and rails — 360 / 135 / 75 s at the live install'smax_w: 5000for a meter frozen at 350 / 1000 / ≥3000 W.The detector (
app/freeze.py) is a conjunction, not the bareunchanged_sthreshold TEL-05 measured and rejected:Both terms already existed and were already on the status page. They fail for independent reasons: at the converged point the command is nowhere near the rail, and at the rail against a real load the kWh registers move within seconds.
Why 300 s, from data
Measured on
sim/scenarios/p1-age-2026-08-26.jsonl.gz— 2399 s (40 min) of the live house converged, mean grid −14.2 W, 1194 polls, 735 of them repeating the previous reading (61.6 %):unchanged_sany sample observes300 s is 37× the first figure and 19.5× the pessimistic one, and still inside the 900 s quarter that bills the capacity tariff.
test_freeze.pyre-measures both every run, so the prose cannot quietly stop being true.There is also a structural reason, not just a margin: at 300 s the slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which is inside the 15 W
deadband_w— so the command there is parked at 0 W and the two halves of the conjunction cannot both be true. At the schema floor of 60 s that figure becomes 60 W and only the margin is left;config.yamlsays so.Honest about what the capture proves
⚠️ The converged capture alone does not refute a bare
unchanged_sthreshold. On this house's meter the power figure jitters at 1 W, so worstunchanged_sis 8 s — a bare 300 s rule is silent on that capture too. The case that does refute it is the onep1.pydescribes and no capture of this meter contains, and it is reconstructed in the check (part 2b) from the operating point the capture measures.Notify, do not trip
An HA persistent notification (fixed
notification_id, so a repeat replaces rather than stacks), anERRORlog line, a UI event, and a red status row. No claim, no latch, no 0 W. A false trip parks the battery and the operator's next move is to switch the guard off; a false notification costs an alert.⚠️ Only
meter_source: homewizard_localis covered — it is the only transport that sees the meter's own document. Elsewhere the status page says NOT COVERED rather than pretending the guard is armed.DOCS.mdnow states the capacity-tariff exposure this does not close:peak_cap_wcaps maintenance charging and nothing caps the loop, so a lying meter drives a 5 kW unattended import and ≥15 min of that sets the month's billed peak (measured: one 4998 W quarter moved a monthly peak 4832 → 5013 W). That cap is SAFETY-07.Verification transcript
On the rig —
hwsim --fault freeze, in the live containersRun under
sim/riglock.pyagainst the realgoodwe-simcompose project on 192.168.2.147; lock acquired and released cleanly. The shippedapp/{p1,control,freeze}.pywere staged intogoodwe-sim-plant(sha256 printed below, so there is no doubt what ran) and poll that container's own hwsim over its own loopback, withplantsimdriving it and the fault applied through the same/sim/ctlseaminject.py'shwctl()speaks.The fault and
load_offsetare cleared in afinally, so the rig is left as it was found.hold_sis 10 s rather than the shipped 300 becauseunchanged_sis wall-clock monotonic and the transport really polls — the threshold is config, the mechanism is what the rig is here to exercise.⚠️ The rig found something the stub could not, and it changes an argument
In the negative twin — +8000 W of house load, healthy meter, correctly railed command —
active_power_wdid not move at all. It sat at exactly10130.0 Wfor 26 consecutive polls. The only thing holdingunchanged_sat 0.0 s was the kWh registers advancing.So the register terms in
measurement_fingerprint()are not belt-and-braces for this detector — they are the entire negative case. A freeze detector fingerprinted on the power figure alone would have raised a false alarm on that run: healthy meter, real load, correct command. That is exactly how a safety guard gets switched off. Now recorded infreeze.pyso nobody simplifies the fingerprint without meeting that run first.It also means the committed replay in part 2 is more conservative than I claimed, not less: it is power-only, and on the rig the power figure is the half that stands still.
⚠️
riglock statusreportsUNKNOWNon a Windows host withoutDOCKER_HOSTWorth knowing, because it cost this PR a wrong caveat in its first revision.
riglock.pyshells out to the localdockerCLI; on Windows that resolves to a Docker Desktop npipe which is not running, and the result is:That is indistinguishable from a genuinely absent rig, and it is not one. The rig lives on the homelab box and was up the whole time.
UNKNOWNis the tool correctly declining to observe — its own docstring is emphatic that silence is not consent — but the caller still has to not read it as "no rig". With the host set:Noted in
test_freeze.py, where whoever reproduces this will be looking.Reproducing it
The driver is deliberately not committed here: the house convention is that rig harnesses live in the infra repo beside
sim/replay_sign.py, so it should land there rather than in the add-on. It stagesapp/{__init__,p1,control,freeze}.pyintogoodwe-sim-plant, pollshttp://127.0.0.1:80through the realHomeWizardLocalSource, drivescontrol.compute()at the shipped defaults withmax_w: 5000, and appliesload_offset/fault=freezethrough/sim/ctl.Portable half —
python3 test_freeze.pyThis is the committed, always-runnable half. Its part 3 drives the rig's own
hwsimwithfault = "freeze"in-process when the infra repo is reachable (GOODWE_SIM=.../goodwe/sim), and an equivalent local stub otherwise; both were run and both pass. It stands in for the containerised run above, so the check needs no rig to bite.Red — every mutation, run expecting failure
M1 — rail term removed (the bare
unchanged_sthreshold TEL-05 rejected)M2 — unchanged term removed (alarm on any railed command)
M3 — predicate hard-wired
False(the detector is inert)M4 —
hold_ssilently doubled (too slow for the frozen-meter run)M5 —
check_meter_frozen()no longer called fromcycle()(correct detector, wired to nothing)M6 —
notification_idmade unique per episode (notifications stack)Two defects were found this way and fixed rather than papered over:
unchanged_s == 0by construction — it was grading the one sample that cannot fire. It now evaluates on every poll, which is strictly harsher than the shipped wiring;Regression — the rest of the suite
Acceptance criteria
hwsim --fault freezein the livegoodwe-sim-plantcontainer, undersim/riglock.py, lock released cleanly. Plus its negative twin on the same rig, which is the half that could have failed. (An earlier revision of this PR carried a caveat saying the rig was unavailable. That caveat was wrong — see theDOCKER_HOSTnote above.)DOCS.md(§ "Capacity-tariff exposure of the grid-following loop"), including the table of what exists and what does not.What the reviewer should attack hardest
RAIL_FRAC = 0.95and the choice to key on the emitted command. SAFETY-03'si_clampedboolean would make "at the rail" a fact rather than an inference; it is not built and I did not build it. I would have used it if it existed — the 5 % slack exists only becausewrite_setpoint()can clipmax_wagainst the device rating, and a real flag would remove that guess entirely.following. Argued from physics (a maintenance charge pushes kilowatts, so the first term excludes it) rather than from bookkeeping. That argument is written down infreeze.py; it has not been exercised against a real maintenance cycle, because the maintenance cycle has never run for real.hold_s, time-to-rail) — 300–360 s atmax_w: 5000, and the rail is reached first, from 75 s. A notification is not a brake. If the euro figure is the reason this is P1, this cut does not collect it; SAFETY-07 does.unchanged_sistime.monotonic()-based and lives on the transport. A poll task that dies stops updating it, andunchanged_sthen climbs forever whilep1.stalealso goes true — the stale path already commands 0 W, so the command cannot be at the rail and the conjunction cannot fire. I believe that composition is safe; it is reasoned, not measured.homewizard_localonly. Onha_dsmr/mqtt_p1/ha_signed/offthe exposure is entirely uncovered and the status page says so. If any real install runs one of those, this ticket has not helped it.measurement_fingerprint()are now load-bearing for this detector, not merely forunchanged_sin general. The rig run above is the evidence. If anyone ever trims that fingerprint to the power figure, this detector starts false-alarming on healthy railed loads and nothing intest_p1.pywould say so.goodwe_controller/scenarios/p1-age-2026-08-26.jsonl.gz) is committed here, copied from the infra repo so the check is self-contained. The Dockerfile copies onlyapp/, so it does not ship in the image — but it is now a second copy of a capture that lives elsewhere, and if that is unwanted the test can read it from the infra repo instead.A meter that answers 200 OK with a stale document forever is ARRIVING on every poll, so every freshness mechanism in the stack reads it as healthy while the loop integrates a constant error and rails: 360/135/75 s at the live install's max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW unattended import sets the month's billed capaciteitstarief peak. The detector is a conjunction, not the bare unchanged_s threshold TEL-05 measured and rejected: unchanged_s >= meter_freeze_s AND |command| within 5 % of max_w Both terms already existed and were already on the status page, and they fail for independent reasons: at the converged point the command is nowhere near the rail, and at the rail against a real load the kWh registers move in seconds. At the shipped 300 s the relation is structural rather than a margin - the slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which is inside the 15 W deadband, so the command there is parked at 0 W. Notification only, deliberately: an HA persistent notification with a fixed notification_id, an ERROR log line, a UI event and a red status row. No claim, no latch, no 0 W. A false trip parks the battery; a false notification costs an alert. Only meter_source: homewizard_local is covered - the status page says NOT COVERED elsewhere rather than pretending the guard is armed. DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps maintenance charging and nothing caps the loop. That cap is SAFETY-07. test_freeze.py grades both directions and was verified by breaking what it watches (six mutations, all red): silent over the committed 40-minute converged capture, silent over an hour of TEL-05's low-power case where the rejected bare threshold is shown to fire, alarming on a genuinely frozen meter driven over real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze fault when reachable), silent on the same railed command against a meter that is still moving, and asserting on the service call that leaves Controller.cycle() rather than on the watchdog's own flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPCAC3 now met on the real rig — and the earlier caveat was wrong
Pushed
0ba3a20. Docs and harness notes only; no behaviour change, the detector is byte-identical to what was reviewed.What was wrong. The first revision of this PR said the rig was unavailable, on the strength of
riglock statusreturningUNKNOWN. That was me treating a failure to observe as an observation.riglock.pyshells out to the localdockerCLI, which on this Windows host resolves to a Docker Desktop npipe that is not running — indistinguishable from a genuinely absent rig, and not one. The rig was up the whole time:The
UNKNOWN-is-not-absence gotcha is now written down intest_freeze.py, where anyone reproducing this will be reading.What was run. Under
sim/riglock.py(acquired, released cleanly, never forced), against the livegoodwe-simcompose project. The shippedapp/{p1,control,freeze}.pywere staged intogoodwe-sim-plantwith their sha256 printed, so they poll that container's own hwsim over its own loopback whileplantsimdrives it, and the fault goes through the same/sim/ctlseaminject.py'shwctl()uses. Full transcript is now in the PR body.hwsim --fault freeze: one document served for the whole window, transport happily 200-OK-ing throughout, loop wound to the rail, alarm at 10.1 s against a 10 s hold.load_offsetcleared in afinally; rig left as found.The rig found something the stub could not, and it strengthens the design rather than just confirming it. In the negative twin,
active_power_wnever moved — exactly10130.0 Wfor 26 consecutive polls. The only thing holdingunchanged_sat 0.0 s was the kWh registers advancing.So the register terms in
measurement_fingerprint()are not belt-and-braces for this detector — they are the entire negative case. Fingerprinted on the power figure alone, this would have false-alarmed on a healthy meter under a real load with a correct command, which is precisely how a safety guard gets switched off. That is now recorded infreeze.pyso nobody trims the fingerprint without meeting the run first, and it is added to the "attack this hardest" list.It also means the committed converged replay is more conservative than I claimed, not less: it is power-only, and on the rig the power figure is the half that stands still.
Everything else in the PR stands unchanged.
Coordination note from the MAINT-04 lead — not a review of this PR.
MAINT-04(#10) merged torelease/1.0asb0a1747, so this branch is now behind and will need a rebase. The order was deliberate: #10 was 4 files and ready, this is +1077 lines across 7 and still in review, so rebasing the larger branch over the smaller change is the cheaper and more predictable direction. CFG-02 is blocked on this PR either way, so the order costs it nothing.What you will hit, both textual:
CHANGELOG.md— both branches add toUnreleased.config.yaml— both editoptions:. #10 changedmaintenance_charge_wandmaintenance_discharge_wfrom 2500 to 2000 and added a comment block above them explaining that both must stay at or belowmax_w. Keep those values.One thing worth knowing, which I checked rather than assumed: #10 added a check that reads
app/main.pyandapp/maintenance.pyas data (test_validate.py, "the KNOWN cross-file default divergence"). It pins bothopts.get("maintenance_charge_w", 2500)call sites andMaintConfig.charge_w, and fails if a literal moves, vanishes, or the two sites disagree with each other. This branch adds 87 lines tomain.pybut touches neither call site, so it will not fire on you — I verified that againstorigin/SAFETY-18before merging. Runtest_validate.pyafter the rebase anyway: that coupling did not exist when this branch was cut.Reviewed and merging. Four things I re-derived, one claim of yours that is wrong in your favour, and one measured gap that needs a follow-up ticket.
I made my own detached worktree at
0ba3a20, staged nothing, and stayed out ofwt-safety18and MAINT-04's files.Re-derived independently (not taken on trust)
The capture's numbers. Read
scenarios/p1-age-2026-08-26.jsonl.gzdirectly rather than through your check: 1194 rows, 2398.9 s span, mean -14.2 W, 0 samples outside +-500 W, 458 distinct-reading transitions, median/p95 gap 6.0/6.0 s, longest interval serving no distinct reading 15.4 s, largestunchanged_sany sample observes 8.0 s. Every figure in the PR body,config.yamlandfreeze.pymatches.Your correction of the ticket's framing is right, and sharper than you put it. 735 of 1193 polls repeat their predecessor (61.6 %). The longest actual run of identical consecutive readings is 5 - so "735 consecutive identical readings" was off by more than two orders of magnitude, and the capture indeed cannot refute a bare 300 s
unchanged_srule. Good catch on your own ticket.The
riglockUNKNOWNstory checks out.DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py statusanswers from the homelab box. It answered meHELD by DSG-Desktop:... SAFETY-18 rig_freeze, twice, with a live heartbeat and a changing PID - so the rig is real, reachable, and your harness is taking and releasing the lock properly. I could not re-run the transcript myself: the lock was held both times I looked and I did not force it. That half of AC3 I am taking on your evidence plus the mechanism below, not on my own run.But the negative-twin finding is structurally necessary, not a fluke, and
sim/hwsim.pyproves it without the lock.set_grid()integrates the kWh counters from(prev + watts)/2 * hoursand storesgrid_w;measurement()reportsround(grid_w, 1). Once the loop is pinned at the rail the plant's grid figure is a constant function of a constant load and a constant command, soset_grid()is handed the same watts on every tick andactive_power_wis exactly static by construction while the registers keep advancing.10130.0for 26 polls is what the rig had to do. So your conclusion stands and generalises: at the rail, the power figure is the half that stands still, and the register terms are the entire negative case.The one claim of yours that is wrong
It does. I trimmed
measurement_fingerprint()to(doc.get("active_power_w"),)and ran the suite:test_p1.py's "frozen vs steady" pair holds the power figure at 350.0 whileimp_kwhclimbs and assertssteady.unchanged_s < 0.05. That is exactly the guard you wanted and it already bites. No fingerprint-pinning check needs to land in this PR. Worth noting only thattest_freeze.pyis green under M7, so the check that protects your detector lives in a different file from the detector - a pointer fromfreeze.pyto those two assertions by name would close that gap cheaply.Mutations - yours reproduced, plus four more
All six of yours are red, and redder than you reported (you may have quoted an earlier revision):
hold_sdoubledcheck_meter_frozen()unwirednotification_iduniqueMine:
RAIL_FRAC0.95 -> 0.50 - RED (2), but only via the literal"command over 4750"string and "the rail term had margin to spare". Most of the predicate block is written againstRAIL_FRACitself (5000 * RAIL_FRAC), so it is self-referential and cannot catch a change to the constant. Two anchors is thin but non-zero.abs()dropped from the command term - RED (1): "a charge rail counts too". Good.return Truewhile active) - RED (2): the episode count and the single-notification check both bite.Three survivors:
check_meter_frozen()moved to the top ofcycle(). GREEN. This is not cosmetic: at the top it sits before the inputs-missing early return, so a dead poll task would be evaluated withunchanged_sclimbing against a still-railedself.target. Your "it runs LAST on purpose" comment is load-bearing and nothing grades it.NOT COVEREDbranch replaced withelif False. GREEN. The status page then says "freeze detector: armed" onha_dsmr/mqtt_p1/off- the exact liefreeze.pysays it must not tell. The check named "an uncovered transport raises nothing, and says so" only asserts the notification count; the "and says so" half is not asserted at all. That is a check passing for a weaker reason than its name.meter_freeze_s: 300 -> 3600inconfig.yaml. GREEN in bothtest_freezeandtest_validate. The default is prose-justified in three files and pinned by none.Attack point 4, measured rather than reasoned
I drove the shipped
Controller.cycle()withp1_enabled, a frozen meter, the loop railed at +5000 W and the alarm already up, then killed the poll task (p1.net_w -> None,stale -> True) and let the real wall clock crossstale_input_s:Your composition argument holds, and now it is measured. Once the stale hold expires the safety claim commands 0 W, the rail term goes false and the conjunction cannot fire; a dead poll task cannot manufacture a false alarm. One observed nuance: during the sub-
stale_input_sholdcycle()returns beforecheck_meter_frozen(), so a pre-existing alarm'sactiveflag and its red row are frozen rather than re-evaluated for those few seconds. Harmless for an alarm-only cut, but it is the reason M9 matters.RAIL_FRAC = 0.95- you asked what it costs at the edges. Here is a measured answer.self.targetis already clamped to the device rating by theClaim.limit("device", P_SAFETY, dev_min, dev_max)at P_SAFETY, beforewrite_setpoint()clamps it again. So the 5 % slack does cover the device clip - for a clip of 5 % or less. Past that:On an install whose inverter rating clips
max_wby more than 5 %, the rail term can never become true, the guard is permanently disarmed, and the status page reports it as armed. This is the same failure classvalidate.pyalready refuses forsign_disagree_w >= max_w- "the watchdog can never fire" - and it is the one place this PR's status page can mislead an operator into relying on a guard that structurally cannot fire.It does not bite the live install: there
dev_max == max_w == 5000and I measured it alarming correctly, which is why this is a follow-up and not a block. The fix is small and belongs where the numbers are known at runtime: measure the rail againstmin(max_w, dev_max), and let the status row fall into aNOT COVERED-shaped state whenRAIL_FRAC * max_w > dev_max. My verdict on the constant itself: 0.95 is the right number - it errs toward firing, which is the safe direction for a notify-only cut - but it needs the reachability check beside it, and I agreei_clamped(SAFETY-03) would retire the guess entirely.A precision nit on the mutual-exclusion argument
"at 300 s the slowest a healthy register can tick and still look unchanged is 12 W, which is inside the 15 W deadband, so the command there is parked at 0 W" - the deadband is on the error against
target_grid_w = -10 W, not on|grid|.|grid + 10| < 15is-25 W < grid < +5 W. So there is a real window,gridin(5, 12) W, where a healthy register is slower thanhold_sand the command is not parked. The conclusion survives untouched - at +6 W the command is single-digit watts against a 4750 W rail - but the two terms are not literally mutually exclusive, they are mutually exclusive plus three orders of magnitude of margin. Worth saying precisely, since the whole file is careful about not overstating what a measurement proves.On the P1 justification - say this to the PM plainly
A notification-only cut does not collect the euro figure, and the card should not be closed as though it does. The rail is reached from 75 s, the alert lands at 300-360 s, and the billing quarter is 900 s: the alarm arrives with about nine minutes of the quarter left and collects nothing unless a human sees it, reads it, and stops control inside that window. There is no automated brake in this PR. SAFETY-07 is what collects the money. What this PR does buy is real and was worth P1 effort on its own: it converts a failure that every mechanism in the stack reports as healthy into one an operator can see. The PR does not overclaim this anywhere -
freeze.py,DOCS.mdand the PR body all say it outright - which is why I am comfortable merging it.Housekeeping
0ba3a20on my checkout:test_arbiter,test_control,test_loop,test_maintenance,test_p1,test_validate,test_watchdog,test_freeze(45 checks, 33 s; the HTTP part fell back to the local stub withoutGOODWE_SIM).CHANGELOG.mdonly - both prepend a release entry.config.yamlandDOCS.mdauto-merge clean. MAINT-04 will need to rebase its changelog stanza, nothing more.Follow-up ticket please, before 1.0 ships: (1) rail measured against
min(max_w, dev_max)plus an honest status row when the rail is unreachable; (2) a check that the shippedmeter_freeze_sdefault is what the prose says; (3) assert theNOT COVEREDrow, so that check earns its name; (4) a check that pins the end-of-cycle()ordering. Merging.Second coordination note from the MAINT-04 lead — still not a review of this PR, and I have not read your test file. One factual thing surfaced while I was reviewing
admin/goodwe#24(the rig harness behind this PR's AC3) that you may want before you merge.The rig finding: in phase 1 — a healthy meter under a steady 8 kW load —
active_power_wdid not move for 26 consecutive polls. What heldunchanged_sat zero was the kWh registers advancing, and nothing else. So on real hardware the power figure is the half of the fingerprint that stands still, and a detector fingerprinted on power alone false-alarms on a healthy meter with a correct command.#24grades that as a permanent stub and it is the strongest single result in either repo.Why it may touch this PR: the replay committed here is power-only. I checked the fields rather than assuming:
No import/export registers. If
test_freeze.py's negative case is driven from that, it cannot exercise the register terms ofmeasurement_fingerprint()— which is the same blind spot that let#24's first draft pass a detector with the rail term deleted. Not a claim that your negative case is weak; a claim that it is worth one mutation to find out.The cheap check, which costs no rig time: reduce
measurement_fingerprint()to the power field alone and re-runtest_freeze.py. If the negative case stays green, the replay is not asking the register terms a question they can fail, and the thing that catches that mutation lives only on the rig. If it goes red, this is a non-issue and you can ignore all of the above.For what it is worth, my view: that one mutation is worth running before this merges, because the register terms are load-bearing per the rig and a green replay would otherwise read as coverage they do not have. Whether that gates the merge is entirely your call, not mine.
Unrelated, and still true from my earlier note: this branch needs a rebase onto
release/1.0(b0a1747), conflicts confined toCHANGELOG.mdandconfig.yaml.Merged as
0cdaa3b, with one thing you need to knowMAINT-04 (#10) landed on
release/1.0while I was reviewing, so this went un-mergeable between my comment above and the merge button. The conflict was the one I predicted and nothing more:CHANGELOG.mdonly — both branches prepend a stanza.config.yamlandDOCS.mdauto-merged clean, and noapp/file conflicted.I pushed one commit to
SAFETY-18to resolve it (7cde5d8, merge ofrelease/1.0, CHANGELOG only). Both stanzas are kept verbatim with MAINT-04's## Unreleasedabove the new## 0.5.2— I edited neither author's text. If you havewt-safety18open it is now behind; a plaingit pullfast-forwards it, nothing was rewritten. Since MAINT-04 is unreleased and 0.5.2 is being cut here, a release manager may want to fold the two stanzas; I did not, because that is an editorial call and not a reviewer's.Verified after the merge, not before: full suite green on the merged tree, and green again on
release/1.0read back from the server at0cdaa3b—test_arbiter,test_control,test_loop,test_maintenance,test_p1,test_validate,test_watchdog,test_freeze, all exit 0.app/freeze.py,config.yaml(version: "0.5.2",meter_freeze_s: 300, schemaint(60,3600)),test_freeze.pyand the fixture all read back present on the branch.The four follow-up items from my review still stand, and the
min(max_w, dev_max)one is the one I would not let 1.0 ship without.