The stale-input watchdog asks whether the meter is still talking. The saturation freeze asks whether the inverter can follow. The output clamp asks whether the number is too big. A loop can pass all three while charging the battery out of the grid, because the only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly that four times in one day, for up to 77 minutes at a stretch, reaching a commanded −14 768 W against a ±5000 W inverter.
New app/watchdog.py: compares the emitted setpoint against the authoritative P1 figure and, when they oppose each other with both over sign_disagree_w (300 W) for sign_disagree_s (180 s) continuously, files a failsafe SET claim at P_SAFETY, commands 0 W, and latches there until an operator starts control again.
Validated against the day it exists for
sim/replay_sign.py (infra repo, admin/goodwe#18, merge this one first) drives this module over the committed RS485 captures of both days:
healthy 2026-08-23: 4212 of 17280 samples gated in, 93.4 % sign agreement (NOTES: 93 %)
fault 2026-08-20: peak commanded value -14768 W (NOTES: -14 768 W)
healthy day 2026-08-23 - the watchdog must stay SILENT
ok 0 trips in 17280 evaluations
fault day 2026-08-20 - the watchdog must fire in every episode
ok episode 1 (15:01 onset): 7 trip(s), first at 15:29:05
ok episode 2 (16:56 onset): 6 trip(s), first at 17:14:00
ok episode 3 (19:57 onset): 3 trip(s), first at 20:04:40
ok episode 4 (20:23 onset): 1 trip(s), first at 20:26:30
ok inverter-off runaway (NOTES 4.7 item 2): 1 trip(s), first at 20:58:55
ok all 18 trips fall inside a documented window; none anywhere else in the day
The four episodes do share one signature rather than merely resembling each other in summary — sign agreement inside them runs 0.3 / 0.9 / 10.5 / 0.0 % against 92.9 % on the healthy day.
Three decisions the ticket could not make
Where it stands: the add-on, not the firmware or the witness. The witness is read-only by charter and could raise the alarm but never take the trip action the AC requires. The firmware has the wire but not the meter — §5.1 makes P1 authoritative and the board only ever receives its age, so the check there would mean shipping the meter to an ESP32 and writing it in C++ per install. The add-on already holds both halves plus an arbiter that can outrank every strategy. The cost, stated in the module: this compares what we meant to put on the wire against the meter, so a sign inversion introduced downstream of the add-on stays invisible here. That is a wire-versus-intent problem and it belongs to the witness.
It is disarmed whenever the grid-following loop is not what is driving. Sign agreement is a property of a loop that follows the meter. A maintenance grid charge opposes the meter by design — 2500 W of charge into a house drawing 500 W is +3000 W on the meter against a −2500 W command, for hours — so armed unconditionally this trips on every maintenance cycle. ⚠️ The gate tests the emitted number, not the winning claim's name: maintenance's charge-only shaping is a hi = -charge_w LIMIT, so the loop can ask for +500 W, win the arbitration, and have the emitted value come out at −2500 W. winner == "loop" alone would have shipped that false trip.
The trip latches. Commanding 0 W removes the command side of the comparison, so the disagreement always clears the instant the trip works. Un-latched, this would trip, clear itself, and hand the actuator straight back to the loop that was driving the wrong way, every three minutes forever.
What will not false-trip it
A battery refusing a command (SoC 10 %, Standby, declining 1350 W of discharge — the state the reference inverter is in right now). A refusal leaves the meter where it was, so command and meter agree. Only the inverter acting moves the meter, and acting in the commanded direction moves it toward zero, not through it.
A frozen meter drives the loop into agreeing with it.
The deadband — at the resting point both signals are inside the 300 W gate.
Slew limiting after a load step does flip the meter's sign before the loop catches up. Measured over a full captured healthy day, the longest such crossing is 30 s, against a 180 s window.
Config, and one new refusal
sign_disagree_w: 300 / sign_disagree_s: 180, per AC 5. Both measured, not chosen, and there is deliberately no off value.
Validator rule 5 refuses sign_disagree_w >= max_w: the arbiter never lets a command exceed max_w, so such a threshold makes the watchdog incapable of firing while the status page still reports it armed. That is CFG-01's (a) — a number the operator set, overridden by a layer they cannot see, with nothing saying so — and a disarmed guard advertising itself as armed is the failure mode this project keeps refusing to ship.
Consequence: test_validate.py's "the smallest sane max_w still starts" case now names a reachable threshold, because a 1 W ceiling genuinely does disarm the 300 W default.
What it does NOT catch
An inverter that acts opposite to its command drives the meter further the same way, so command and meter agree while everything runs away. That is the saturation freeze's job (|command − 0x0516[2]|). The two are complementary and neither substitutes for the other. Said out loud in watchdog.py, DOCS.md and NOTES.md so nobody assumes otherwise.
Tests
test_watchdog.py: 39 checks — the two fault directions, the exact boundaries (300 W is strictly-greater, so it does not qualify), continuity broken at 179 s and resumed, missing/stale readings resetting rather than accumulating, latch persistence, config-driven thresholds, the validator interaction, and an end-to-end case that drives a real Controller through a trip and asserts 0 W reached the device via the failsafe claim.
Closes SAFETY-02.
The stale-input watchdog asks whether the meter is still talking. The saturation freeze asks whether the inverter can follow. The output clamp asks whether the number is too big. **A loop can pass all three while charging the battery out of the grid, because the only thing wrong is the sign.** On 2026-08-20 a commercial controller did exactly that four times in one day, for up to 77 minutes at a stretch, reaching a commanded −14 768 W against a ±5000 W inverter.
New `app/watchdog.py`: compares the emitted setpoint against the authoritative P1 figure and, when they oppose each other with both over `sign_disagree_w` (300 W) for `sign_disagree_s` (180 s) **continuously**, files a failsafe `SET` claim at `P_SAFETY`, commands **0 W**, and latches there until an operator starts control again.
## Validated against the day it exists for
`sim/replay_sign.py` (infra repo, admin/goodwe#18, merge this one first) drives this module over the committed RS485 captures of both days:
```
healthy 2026-08-23: 4212 of 17280 samples gated in, 93.4 % sign agreement (NOTES: 93 %)
fault 2026-08-20: peak commanded value -14768 W (NOTES: -14 768 W)
healthy day 2026-08-23 - the watchdog must stay SILENT
ok 0 trips in 17280 evaluations
fault day 2026-08-20 - the watchdog must fire in every episode
ok episode 1 (15:01 onset): 7 trip(s), first at 15:29:05
ok episode 2 (16:56 onset): 6 trip(s), first at 17:14:00
ok episode 3 (19:57 onset): 3 trip(s), first at 20:04:40
ok episode 4 (20:23 onset): 1 trip(s), first at 20:26:30
ok inverter-off runaway (NOTES 4.7 item 2): 1 trip(s), first at 20:58:55
ok all 18 trips fall inside a documented window; none anywhere else in the day
```
The four episodes **do** share one signature rather than merely resembling each other in summary — sign agreement inside them runs 0.3 / 0.9 / 10.5 / 0.0 % against 92.9 % on the healthy day.
## Three decisions the ticket could not make
**Where it stands: the add-on, not the firmware or the witness.** The witness is read-only by charter and could raise the alarm but never take the trip action the AC requires. The firmware has the wire but not the meter — §5.1 makes P1 authoritative and the board only ever receives its *age*, so the check there would mean shipping the meter to an ESP32 and writing it in C++ per install. The add-on already holds both halves plus an arbiter that can outrank every strategy. The cost, stated in the module: this compares what we *meant* to put on the wire against the meter, so a sign inversion introduced downstream of the add-on stays invisible here. That is a wire-versus-intent problem and it belongs to the witness.
**It is disarmed whenever the grid-following loop is not what is driving.** Sign agreement is a property of a loop that follows the meter. A maintenance grid charge opposes the meter *by design* — 2500 W of charge into a house drawing 500 W is +3000 W on the meter against a −2500 W command, for hours — so armed unconditionally this trips on every maintenance cycle. ⚠️ The gate tests the emitted **number**, not the winning claim's name: maintenance's charge-only shaping is a `hi = -charge_w` LIMIT, so the loop can ask for +500 W, win the arbitration, and have the emitted value come out at −2500 W. `winner == "loop"` alone would have shipped that false trip.
**The trip latches.** Commanding 0 W removes the command side of the comparison, so the disagreement always clears the instant the trip works. Un-latched, this would trip, clear itself, and hand the actuator straight back to the loop that was driving the wrong way, every three minutes forever.
## What will not false-trip it
- **A battery refusing a command** (SoC 10 %, Standby, declining 1350 W of discharge — the state the reference inverter is in right now). A refusal leaves the meter where it was, so command and meter *agree*. Only the inverter acting moves the meter, and acting in the commanded direction moves it toward zero, not through it.
- **A frozen meter** drives the loop into agreeing with it.
- **The deadband** — at the resting point both signals are inside the 300 W gate.
- **Slew limiting after a load step** does flip the meter's sign before the loop catches up. Measured over a full captured healthy day, the longest such crossing is **30 s**, against a 180 s window.
## Config, and one new refusal
`sign_disagree_w: 300` / `sign_disagree_s: 180`, per AC 5. Both measured, not chosen, and there is deliberately no `off` value.
Validator rule 5 refuses `sign_disagree_w >= max_w`: the arbiter never lets a command exceed `max_w`, so such a threshold makes the watchdog incapable of firing **while the status page still reports it armed**. That is CFG-01's (a) — a number the operator set, overridden by a layer they cannot see, with nothing saying so — and a disarmed guard advertising itself as armed is the failure mode this project keeps refusing to ship.
Consequence: `test_validate.py`'s "the smallest sane max_w still starts" case now names a reachable threshold, because a 1 W ceiling genuinely does disarm the 300 W default.
## What it does NOT catch
An inverter that *acts* opposite to its command drives the meter **further the same way**, so command and meter agree while everything runs away. That is the saturation freeze's job (`|command − 0x0516[2]|`). The two are complementary and neither substitutes for the other. Said out loud in `watchdog.py`, `DOCS.md` and `NOTES.md` so nobody assumes otherwise.
## Tests
`test_watchdog.py`: 39 checks — the two fault directions, the exact boundaries (300 W is strictly-greater, so it does not qualify), continuity broken at 179 s and resumed, missing/stale readings resetting rather than accumulating, latch persistence, config-driven thresholds, the validator interaction, and an **end-to-end** case that drives a real `Controller` through a trip and asserts 0 W reached the device via the failsafe claim.
All seven suites pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
The stale-input watchdog asks whether the meter is still talking. The saturation
freeze asks whether the inverter can follow. The output clamp asks whether the
number is too big. A loop can pass all three while charging the battery out of
the grid, because the only thing wrong is the sign.
On 2026-08-20 a commercial controller did exactly that four times in one day,
for up to 77 minutes at a stretch, reaching a commanded -14 768 W against a
±5000 W inverter. app/watchdog.py compares the emitted setpoint against the
authoritative P1 figure and, when they oppose each other with both over 300 W
for 180 s continuously, files a failsafe SET claim at P_SAFETY, commands 0 W and
latches there until an operator starts control again.
Validated against that day rather than against an argument: sim/replay_sign.py
in the infra repo drives this module over the committed captures of both
2026-08-20 and the healthy 2026-08-23 and reaches 18 trips on the fault day -
every one inside a window NOTES.md §4.7 accounts for - and 0 on the healthy day.
The four episodes do share one signature: 0.3 / 0.9 / 10.5 / 0.0 % sign
agreement inside them against 92.9 % healthy.
Three decisions the ticket could not make:
- It is DISARMED whenever the grid-following loop is not what is driving. Sign
agreement is a property of a loop that follows the meter, and a maintenance
grid charge opposes the meter by design - 2500 W of charge into a house
drawing 500 W is +3000 W on the meter against a -2500 W command, for hours.
The gate tests the emitted number, not the winning claim's name, because a
limit claim can let the loop "win" and still replace its value.
- The trip LATCHES. Commanding 0 W removes the command side of the comparison,
so the disagreement always clears the moment the trip works.
- sign_disagree_w at or above max_w is refused at startup. The arbiter never
lets a command exceed max_w, so such a threshold leaves the watchdog incapable
of firing while the status page still reports it armed.
What it does not catch, said out loud: an inverter that ACTS opposite to its
command moves the meter further the same way, leaving command and meter in
agreement. That is the saturation freeze's job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
admin
merged commit 2513ba1f12 into release/1.02026-08-28 21:55:52 +02:00
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.
Closes SAFETY-02.
The stale-input watchdog asks whether the meter is still talking. The saturation freeze asks whether the inverter can follow. The output clamp asks whether the number is too big. A loop can pass all three while charging the battery out of the grid, because the only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly that four times in one day, for up to 77 minutes at a stretch, reaching a commanded −14 768 W against a ±5000 W inverter.
New
app/watchdog.py: compares the emitted setpoint against the authoritative P1 figure and, when they oppose each other with both oversign_disagree_w(300 W) forsign_disagree_s(180 s) continuously, files a failsafeSETclaim atP_SAFETY, commands 0 W, and latches there until an operator starts control again.Validated against the day it exists for
sim/replay_sign.py(infra repo, admin/goodwe#18, merge this one first) drives this module over the committed RS485 captures of both days:The four episodes do share one signature rather than merely resembling each other in summary — sign agreement inside them runs 0.3 / 0.9 / 10.5 / 0.0 % against 92.9 % on the healthy day.
Three decisions the ticket could not make
Where it stands: the add-on, not the firmware or the witness. The witness is read-only by charter and could raise the alarm but never take the trip action the AC requires. The firmware has the wire but not the meter — §5.1 makes P1 authoritative and the board only ever receives its age, so the check there would mean shipping the meter to an ESP32 and writing it in C++ per install. The add-on already holds both halves plus an arbiter that can outrank every strategy. The cost, stated in the module: this compares what we meant to put on the wire against the meter, so a sign inversion introduced downstream of the add-on stays invisible here. That is a wire-versus-intent problem and it belongs to the witness.
It is disarmed whenever the grid-following loop is not what is driving. Sign agreement is a property of a loop that follows the meter. A maintenance grid charge opposes the meter by design — 2500 W of charge into a house drawing 500 W is +3000 W on the meter against a −2500 W command, for hours — so armed unconditionally this trips on every maintenance cycle. ⚠️ The gate tests the emitted number, not the winning claim's name: maintenance's charge-only shaping is a
hi = -charge_wLIMIT, so the loop can ask for +500 W, win the arbitration, and have the emitted value come out at −2500 W.winner == "loop"alone would have shipped that false trip.The trip latches. Commanding 0 W removes the command side of the comparison, so the disagreement always clears the instant the trip works. Un-latched, this would trip, clear itself, and hand the actuator straight back to the loop that was driving the wrong way, every three minutes forever.
What will not false-trip it
Config, and one new refusal
sign_disagree_w: 300/sign_disagree_s: 180, per AC 5. Both measured, not chosen, and there is deliberately nooffvalue.Validator rule 5 refuses
sign_disagree_w >= max_w: the arbiter never lets a command exceedmax_w, so such a threshold makes the watchdog incapable of firing while the status page still reports it armed. That is CFG-01's (a) — a number the operator set, overridden by a layer they cannot see, with nothing saying so — and a disarmed guard advertising itself as armed is the failure mode this project keeps refusing to ship.Consequence:
test_validate.py's "the smallest sane max_w still starts" case now names a reachable threshold, because a 1 W ceiling genuinely does disarm the 300 W default.What it does NOT catch
An inverter that acts opposite to its command drives the meter further the same way, so command and meter agree while everything runs away. That is the saturation freeze's job (
|command − 0x0516[2]|). The two are complementary and neither substitutes for the other. Said out loud inwatchdog.py,DOCS.mdandNOTES.mdso nobody assumes otherwise.Tests
test_watchdog.py: 39 checks — the two fault directions, the exact boundaries (300 W is strictly-greater, so it does not qualify), continuity broken at 179 s and resumed, missing/stale readings resetting rather than accumulating, latch persistence, config-driven thresholds, the validator interaction, and an end-to-end case that drives a realControllerthrough a trip and asserts 0 W reached the device via the failsafe claim.All seven suites pass.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa