Commit Graph
6 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 cc4e88490b SAFETY-02 review: a latch that a restart clears is not a latch
BLOCKER. `auto` was persisted and `sign_wd.tripped` was not, so reconstructing
the Controller against the same store - which is what a container restart does -
brought it back ARMED, into the same fault, unattended: tripped=False, banner
back to NOT READY, and it resumed commanding -1000 then -2000 W before
re-tripping 180 s later. "Restart the add-on" is the first thing an operator
looking at a FAILSAFE banner and a 0 W inverter will try, and under a supervisor
that restarts a crashed add-on it is a loop of three-minute wrong-direction
bursts. DOCS.md's "it does not clear itself" was false across a restart.

`sign_tripped` and `sign_reason` are Store keys now, beside the maintenance
state and for the reason store.py's own docstring gives. Written the moment the
trip happens - reason first, flag second, so a crash between the two can never
persist a latch with no explanation - restored in Controller.__init__, cleared
only by the operator's start. The regression is covered: reverting the restore
fails seven checks in test_watchdog.py.

Review follow-ups, no behaviour change:

- The arming gate is tested against the REAL arbiter. It is the decision the
  ticket turns on and it was asserted in three comment blocks and verified by
  nothing - every case passed `following` in as a literal. Twelve claim
  configurations now derive it from resolve(), including the redirect case
  `winner == "loop"` alone would have got wrong and the `lo` limit that raises
  magnitude without flipping sign.
- The MQTT `status: "failsafe"` value is tested. The check that claimed to cover
  it restated the line above it and never touched the payload.
- The worked example described a config that does not ship: at the defaults
  maintenance_charge_w 2500 against max_w 2000 is an arbiter contradiction and
  0 W, not a -2500 W charge. Rewritten at 1500 W. The contradiction is a real
  pre-existing bug - a default cheap-window maintenance charge silently never
  happens - filed separately and deliberately not fixed here.
- Forward hazard recorded at the gate: `winner == "loop"` disarms the watchdog
  under any higher-priority SET, and a peak-shaving strategy at P_PEAK IS
  grid-following in sign, so it would blind this for every peak event.
- disagrees() is public so the replay harness stops carrying its own copy of the
  predicate it grades.
- held_s takes the clock instead of reading one.
- The two limits are now each stated in both places rather than one each.
- The no-false-positive argument is tied to control.py's freeze permitting the
  command to cross zero; under the shrink-only encoding control.py records as
  rejected, this would false-trip on a load step.
- Measured figures corrected: 93.4 % / 4212 of 17280, per-cluster
  0.3/0.9/10.5/0.0, and the near-circularity of those figures is now stated
  rather than leaned on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 22:33:15 +02:00
glenn schrooyenandClaude Opus 5 9aed43990b SAFETY-02: a loop can be fresh, tracking and inside its rating and still push the wrong way
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
2026-08-28 21:51:35 +02:00
glenn schrooyenandClaude Opus 5 aae74d7571 SAFETY-12 review: name the safety claim that could not fire, and fix two figures
Review fixes.

A. test_loop.py takes 73 s, not the ~35 s claimed in three places. That number
is the whole defence against a future developer deleting a slow test, so it was
understated by 2x in exactly the place it matters. Corrected - and it is now
~95 s (measured 93 s), because this commit adds the scenario below rather than
trimming windows to make an inconvenient number smaller. Both call sites carry
an instruction to re-measure if the windows change.

B. "242 checks" was test_p1's own count quoted as the total for all five
suites. The real figure is 395 across the five, plus 14 here.

And the finding this PR should have led with. stale_input_s and the
`inputs missing -> 0 W` claim both live INSIDE cycle(), so pre-fix, on a meter
whose value happened to repeat, that claim could not fire at all. Constant
meter, SoC entity dying at t=4 s, one binary:

  pre-fix   final target 220.0 W, reason 'loop -> 220 W'
  post-fix  final target   0.0 W, reason 'safety -> 0 W'

The old controller held 220 W through the whole input outage with its status
line still reading loop -> 220 W, and layer 1 does not cover that on a
`meter_source: off` install - no P1 age sensor is published, so the firmware has
nothing to veto on, while the heartbeat keeps its watchdog fed. The pre-fix
behaviour was not "holds a stale command safely"; it was a failsafe a repeating
meter switched off. Asserted in test_loop.py, both halves.

Three points of reasoning tightened, none of them wrong before:

- the re-tune is unreachable by construction, not merely by the reset: the
  combined cycle rate is max(arrival rate, 1/floor), never the sum, and 1/15 s
  is strictly slower than the ~1/5 s arrival rate. That holds only while the
  floor exceeds the meter's update interval - and the schema minimum of 5 s is
  exactly the reference meter's interval. Flagged for validate.py, not fixed
  here.
- the ~11 s lower bracket is not only a dropped poll: a 5.0 s poller against a
  4.97 s meter beats, so about every 14 minutes the poller steps over an update
  entirely. Reaching 15 s takes a beat-skip AND a failed poll.
- the "freshness is decided upstream" guarantee is transport-conditional. On
  `meter_source: off` the grid figure comes straight from hass.number() with no
  age gate. Running the full law is still right; the claim is narrower than it
  was stated.

The 15 s default is not independently justified - it rests on the firmware's
30 s ingest veto and moves if that ever does. Said beside the constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 21:14:58 +02:00
glenn schrooyenandClaude Opus 5 96a808a057 HANDOVER: run every suite before deploying, and say why one is slow
The pre-deploy command listed three of the five suites; test_validate and
test_p1 were never added when they arrived. Adds those and test_loop, with a
note that test_loop's ~35 s is the cost of proving a wall-clock property about a
loop that sleeps 1 s a tick rather than a sign something is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:32:00 +02:00
glenn schrooyenandClaude Opus 5 0422127cab HANDOVER said 0.2.0; the running add-on is 0.4.0
Two versions stale. 0.3.0 shipped SAFETY-04 and TEL-01, 0.4.0 shipped TEL-04
and TEL-05 and changed the meter transport to homewizard_local - so a reader
taking this line at face value would look for a meter path that is no longer
how this system reads the grid.

Found in the 2026-08-27 backlog audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-27 23:55:37 +02:00
adminandClaude Opus 5 1b85eb41ad Handover doc: state, known breakage, and what to do next
Written for a cold start. Covers what is actually running (0.2.0, on a real
battery), how to deploy without falling into the version-bump trap, the four
things that are known broken, and the ranked next steps.

Leads with the two facts most likely to cause harm if missed: never run the
add-on and the old YAML packages together, and a "-20"/G2 inverter cannot be
driven by this at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:51:34 +02:00