SAFETY-18: alarm on a meter frozen at a wrong-but-fresh value #11

Merged
admin merged 3 commits from SAFETY-18 into release/1.0 2026-08-30 03:20:38 +02:00
Owner

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

  • 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

  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.
## 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.
admin added 1 commit 2026-08-30 02:41:59 +02:00
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
admin added 1 commit 2026-08-30 02:49:56 +02:00
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
Author
Owner

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.

## 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.
Author
Owner

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.
Author
Owner

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.

## 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.
admin added 1 commit 2026-08-30 03:20:28 +02:00
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.0 2026-08-30 03:20:38 +02:00
Author
Owner

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.

**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`.
Author
Owner

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.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#11