SAFETY-128: control does not act on late Home Assistant readings.
Problem (SAFETY-127 report): readings that Home Assistant delivers late but still changing pass every staleness gate. The loop corrects, each cycle, an error its last command already fixed, and the give run swings with growing export (10 s late: commands -450..+3 790 W, grid to -1.9 kW). The peak guard pushed to the inverter limit on the same late figures (OPT-34 F-2).
Delay fits, every 4 s over the last 40 s of new meter values, how long after a power request the meter shows it (meter(t) + request(t - d) is flattest at the right d). Over DELAY_S = 8 s the readings are late. Guards against false positives: the requests must explain half the meter's swing; a late d must fit 30 % better than any fresh one and better than a slow battery (10/20 s lag) answering at once; only new meter values count (a silent meter is not late); a battery giving nothing or refusing is left out.
Nothing else acts unless readings are found late, so with readings on time control is unchanged (digests identical).
Late: a request more than ANSWER_W = 100 W from the battery reading is pending until the reading answers it; meanwhile the loop may not lead the reading by more than 100 W that way (compute(hold=, hold_w=)) and the peak guard's floor is capped at what stands (peakguard.cap_floor()).
Pending for LATE_S = 23 s (6 s dead time + 6 s settle + 10 s publish + 1 s read) with no reading moving towards it: hold, then 0 W after stale_input_s ("readings late").
Seen: amber row "Home Assistant readings arrive about N seconds late, so control holds the batteries." / "Metingen uit Home Assistant komen zo'n N seconden te laat, dus de batterijen wachten."; one WARNING READINGS_LATE and one action-log line per episode.
Tests: test_safety128.py (real cycle(), TEST-23's plant in tools/safety128_rig.py). Without the gate (= 77fe31b) red: export below -1 kW and a growing swing at 10 s and 30 s late; the guard at 9 600 W in a quarter's last 30 s. With it green: within 250 W of the last good command, no export below -200 W, settled at once after the lag; the guard never at the limit. No-lag runs at 1/2/5 s meters, a 6 s dead time, start-up, cut-out level, a refusing battery, a 10/20 s slow battery: same commands, never taken for late.
Changed existing tests: test_peakguard.py counts 18 direct claim appends (the new "readings late" claim); test_safety94.py and test_safety96.py take the new gate out of their "before the fix" runs too.
Mutations, graded over test_safety128.py: no loop hold, no peak cap, never late, hold ignores exempt, no 0 W claim, no slow-battery check: all red.
SAFETY-128: control does not act on late Home Assistant readings.
**Problem** (SAFETY-127 report): readings that Home Assistant delivers late but still changing pass every staleness gate. The loop corrects, each cycle, an error its last command already fixed, and the give run swings with growing export (10 s late: commands -450..+3 790 W, grid to -1.9 kW). The peak guard pushed to the inverter limit on the same late figures (OPT-34 F-2).
**Design** (`app/lateness.py`, DECISIONS/SAFETY-128-late-readings.md):
- `Delay` fits, every 4 s over the last 40 s of new meter values, how long after a power request the meter shows it (`meter(t) + request(t - d)` is flattest at the right `d`). Over `DELAY_S` = 8 s the readings are late. Guards against false positives: the requests must explain half the meter's swing; a late `d` must fit 30 % better than any fresh one and better than a slow battery (10/20 s lag) answering at once; only new meter values count (a silent meter is not late); a battery giving nothing or refusing is left out.
- Nothing else acts unless readings are found late, so with readings on time control is unchanged (digests identical).
- Late: a request more than `ANSWER_W` = 100 W from the battery reading is pending until the reading answers it; meanwhile the loop may not lead the reading by more than 100 W that way (`compute(hold=, hold_w=)`) and the peak guard's floor is capped at what stands (`peakguard.cap_floor()`).
- Pending for `LATE_S` = 23 s (6 s dead time + 6 s settle + 10 s publish + 1 s read) with no reading moving towards it: hold, then 0 W after `stale_input_s` ("readings late").
- Seen: amber row "Home Assistant readings arrive about N seconds late, so control holds the batteries." / "Metingen uit Home Assistant komen zo'n N seconden te laat, dus de batterijen wachten."; one WARNING `READINGS_LATE` and one action-log line per episode.
**Tests**: `test_safety128.py` (real `cycle()`, TEST-23's plant in `tools/safety128_rig.py`). Without the gate (= 77fe31b) red: export below -1 kW and a growing swing at 10 s and 30 s late; the guard at 9 600 W in a quarter's last 30 s. With it green: within 250 W of the last good command, no export below -200 W, settled at once after the lag; the guard never at the limit. No-lag runs at 1/2/5 s meters, a 6 s dead time, start-up, cut-out level, a refusing battery, a 10/20 s slow battery: same commands, never taken for late.
Changed existing tests: `test_peakguard.py` counts 18 direct claim appends (the new "readings late" claim); `test_safety94.py` and `test_safety96.py` take the new gate out of their "before the fix" runs too.
Mutations, graded over test_safety128.py: no loop hold, no peak cap, never late, hold ignores exempt, no 0 W claim, no slow-battery check: all red.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Readings that Home Assistant delivers late but still changing passed
every staleness gate, so the loop corrected again each cycle an error
its last command had already fixed (10 s late: the give run swung and
exported to -1.9 kW), and the peak guard pushed to the inverter limit
on the same late figures (OPT-34 F-2).
app/lateness.py fits how long after a power request the meter shows
it. Over 8 s the readings are late: a request the battery reading has
not answered holds the loop (it may not lead the reading by more than
100 W) and caps the peak guard at what stands; unanswered for 23 s it
holds, then 0 W after stale_input_s. An amber row and one log line per
episode say so. With readings on time nothing changes (digests equal).
DECISIONS/SAFETY-128-late-readings.md; test_safety128.py.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Review of 09e1fbb (SAFETY-128): changes requested. Not merged.
CI run 1501 checks / unit (pull_request) is green on 09e1fbb (checked through the API). The local suite result is below. Mutants 1, 2, 5 and 6 rebuilt by me from head: all red. Digests modes_off, arb_epex, own_fresh_state_epex and n1_modes_off at the replay's 5 s tick: base and head identical (sha c1b230c2, e5230acc, 558439f3, 80d42c7d). But see B2: at 5 s the detector never runs, so these digests do not test it.
Blockers
B1. False positives on healthy sites with a 1 s or 2 s meter.app/lateness.py:96-123. All runs below go through the real cycle() (tools/safety128_rig.py plant) with no lag:
Scenario (no lag)
late_seen
WARNING episodes
fitted "delay"
oven cycling 2 kW 45 s on / 60 s off, 2 s meter, N=2
668 s of 1 500
16
39-57 s
same, 1 s meter, N=1
388 s
7
43-58 s
oven 60/60, 2 s meter, N=2
716 s
17
57 s
owner-shaped house (kettle, oven, heat pump, clouds), 2 s, N=2
1 144 s of 4 500
10
37-89 s
same, 1 s meter, N=2 / N=1
120 s / 360 s
43-59 s
battery rate-limited 300 W/s, 1 s meter
704 s
4
39-59 s
HA delivery jitter 0-4 s, 1 s meter
726 s
13
13-89 s
8 s command dead time, 1 s meter
252 s
up to 79 s
recorded house, 2 days, replay at a 2 s tick, modes off, N=2
8 316 of 86 400 cycles (9.6 %)
572 late fits, up to 89 s
same, owner's modes (A Leftover solar, B Cheap hours)
7 544 of 86 400 cycles (8.7 %)
522 late fits
Harm while held on a healthy site (oven 45/60, 2 s, N=2, gate against no gate):
export 83 against 29 Wh;
highest 60 s grid average 387 against 124 W (N=1, 1 s meter: 982 against 132 W);
seconds of export below -200 W: 331 against 66;
command steps over 500 W: 105 against 50.
The replays change commands too: 49 986 against 49 808 set_number calls, different sha.
Cause: the fit assumes the house is flat in the 40 s window. But the loop's requests echo the house steps it chases. When the house steps inside the window, the true delay fails FIT_SHARE, and an earlier echo of a repeating load lines up as a "late" delay. FIT_BETTER only compares against d <= 8, and those delays are poor fits in exactly that case. Repeating loads (an oven, a hob or a heat pump cycling) are normal in houses.
B2. With a 5 s meter the detector never runs.app/lateness.py:86,91: len(rows) < 10 with FIT_S 40 s allows at most 9 new values at 5 s. The owner's P1 is polled every 5 s, and control.py:495 names 5 s as the reference meter. Rig runs at a 5 s meter, N=2, house 1 800 W:
10 s late: 0 fits;
30 s late: 0 fits, grid -1 325..+2 185 W, the same as without the gate.
So the fix does nothing on the reference setup. The dev report's "5 s meter: 0 W difference" check and the 5 s digests (0 fits in 3 days) prove that it is inert, not that it is correct. Neither DECISIONS nor the report states this limit.
Major
M1. No hysteresis on late_seen (lateness.py:189-191, main.py:3137-3149). The verdict flips on every 4 s fit. Each flip logs one WARNING and one action-log line, so 14-17 WARNINGs came in 20 min in the runs above. "One per episode" holds only because each episode is short.
M2. Unbounded CPU in the slow-battery check (lateness.py:114-120). It simulates every second from cmds[0], which can be hours old after a long constant command. Worst estimate() on this x86 host: 3 ms normally, 110 ms after 1 day idle, 315 ms after 3 days. On a Pi that blocks the event loop for seconds, once every 4 s, for about 130 s. Starting at rows[0][0] - MAX_DELAY_S bounds it.
Minor
The row's N is the fitted meter delay: plant answer plus one cycle, not HA's own lag. The first fit can be far off (the dev report says 69 s for a 30 s lag).
The WARNING says "holds the battery", the row says "holds the batteries".
The limits the dev names
48-68 s before detection: acceptable for a detector, if B1 and B2 are fixed.
The ~900 W ping-pong at extreme lag, and the arbitrage charge_room_w() swing (-8 678 W at 10 s late): neither is a blocker for this PR, but both need cards.
0 W reached only through a frozen gate: acceptable as a backstop.
Not changed by me
Tests changed: test_peakguard.py 17 -> 18 counts the new claim. That is honest.
test_safety94/96.py: switching the gate off in the "before" counterfactual is honest too, since that tree had no gate. But it shows the detector fires on a controller swinging against a slow battery with readings on time, which is the same weakness as B1.
Text, CHANGELOG and DECISIONS are short and plain (UX-08). The DECISIONS claim "with readings on time control is unchanged" is not true (B1).
Needed before merge: a detector with no late verdict in the B1 scenarios and in a recorded-house replay at 1 s and 2 s meters, one that works at a 5 s meter (or the limit stated and accepted by the owner), hysteresis, and a bounded slow-battery check.
**Review of 09e1fbb (SAFETY-128): changes requested. Not merged.**
CI run 1501 `checks / unit (pull_request)` is green on 09e1fbb (checked through the API). The local suite result is below. Mutants 1, 2, 5 and 6 rebuilt by me from head: all red. Digests modes_off, arb_epex, own_fresh_state_epex and n1_modes_off at the replay's 5 s tick: base and head identical (sha c1b230c2, e5230acc, 558439f3, 80d42c7d). But see B2: at 5 s the detector never runs, so these digests do not test it.
### Blockers
**B1. False positives on healthy sites with a 1 s or 2 s meter.** `app/lateness.py:96-123`. All runs below go through the real `cycle()` (tools/safety128_rig.py plant) with **no lag**:
| Scenario (no lag) | late_seen | WARNING episodes | fitted "delay" |
|---|---|---|---|
| oven cycling 2 kW 45 s on / 60 s off, 2 s meter, N=2 | 668 s of 1 500 | 16 | 39-57 s |
| same, 1 s meter, N=1 | 388 s | 7 | 43-58 s |
| oven 60/60, 2 s meter, N=2 | 716 s | 17 | 57 s |
| owner-shaped house (kettle, oven, heat pump, clouds), 2 s, N=2 | 1 144 s of 4 500 | 10 | 37-89 s |
| same, 1 s meter, N=2 / N=1 | 120 s / 360 s | | 43-59 s |
| battery rate-limited 300 W/s, 1 s meter | 704 s | 4 | 39-59 s |
| HA delivery jitter 0-4 s, 1 s meter | 726 s | 13 | 13-89 s |
| 8 s command dead time, 1 s meter | 252 s | | up to 79 s |
| **recorded house, 2 days, replay at a 2 s tick, modes off, N=2** | 8 316 of 86 400 cycles (9.6 %) | | 572 late fits, up to 89 s |
| **same, owner's modes (A Leftover solar, B Cheap hours)** | 7 544 of 86 400 cycles (8.7 %) | | 522 late fits |
Harm while held on a healthy site (oven 45/60, 2 s, N=2, gate against no gate):
- export 83 against 29 Wh;
- highest 60 s grid average 387 against 124 W (N=1, 1 s meter: 982 against 132 W);
- seconds of export below -200 W: 331 against 66;
- command steps over 500 W: 105 against 50.
The replays change commands too: 49 986 against 49 808 set_number calls, different sha.
Cause: the fit assumes the house is flat in the 40 s window. But the loop's requests echo the house steps it chases. When the house steps inside the window, the true delay fails `FIT_SHARE`, and an earlier echo of a repeating load lines up as a "late" delay. `FIT_BETTER` only compares against `d <= 8`, and those delays are poor fits in exactly that case. Repeating loads (an oven, a hob or a heat pump cycling) are normal in houses.
**B2. With a 5 s meter the detector never runs.** `app/lateness.py:86,91`: `len(rows) < 10` with `FIT_S` 40 s allows at most 9 new values at 5 s. The owner's P1 is polled every 5 s, and `control.py:495` names 5 s as the reference meter. Rig runs at a 5 s meter, N=2, house 1 800 W:
- 10 s late: 0 fits;
- 30 s late: 0 fits, grid -1 325..+2 185 W, the same as without the gate.
So the fix does nothing on the reference setup. The dev report's "5 s meter: 0 W difference" check and the 5 s digests (0 fits in 3 days) prove that it is inert, not that it is correct. Neither DECISIONS nor the report states this limit.
### Major
- **M1. No hysteresis on `late_seen`** (`lateness.py:189-191`, `main.py:3137-3149`). The verdict flips on every 4 s fit. Each flip logs one WARNING and one action-log line, so 14-17 WARNINGs came in 20 min in the runs above. "One per episode" holds only because each episode is short.
- **M2. Unbounded CPU in the slow-battery check** (`lateness.py:114-120`). It simulates every second from `cmds[0]`, which can be hours old after a long constant command. Worst `estimate()` on this x86 host: 3 ms normally, 110 ms after 1 day idle, 315 ms after 3 days. On a Pi that blocks the event loop for seconds, once every 4 s, for about 130 s. Starting at `rows[0][0] - MAX_DELAY_S` bounds it.
### Minor
- The row's N is the fitted meter delay: plant answer plus one cycle, not HA's own lag. The first fit can be far off (the dev report says 69 s for a 30 s lag).
- The WARNING says "holds the battery", the row says "holds the batteries".
### The limits the dev names
- 48-68 s before detection: acceptable for a detector, if B1 and B2 are fixed.
- The ~900 W ping-pong at extreme lag, and the arbitrage `charge_room_w()` swing (-8 678 W at 10 s late): neither is a blocker for this PR, but both need cards.
- 0 W reached only through a frozen gate: acceptable as a backstop.
### Not changed by me
- Tests changed: `test_peakguard.py` 17 -> 18 counts the new claim. That is honest.
- `test_safety94/96.py`: switching the gate off in the "before" counterfactual is honest too, since that tree had no gate. But it shows the detector fires on a controller swinging against a slow battery with readings on time, which is the same weakness as B1.
- Text, CHANGELOG and DECISIONS are short and plain (UX-08). The DECISIONS claim "with readings on time control is unchanged" is not true (B1).
Needed before merge: a detector with no late verdict in the B1 scenarios and in a recorded-house replay at 1 s and 2 s meters, one that works at a 5 s meter (or the limit stated and accepted by the owner), hysteresis, and a bounded slow-battery check.
Review B1: the meter fit took repeating house loads (ovens, a heat pump)
for late readings on healthy 1-2 s sites, about 9 % of the recorded
house. B2: at a 5 s meter it never ran. M1: no hysteresis. M2: an
unbounded check.
lateness.Delay now fits how long each battery's reading takes to show
its own request, which the house does not move, from the 1 s reads
(read_inputs -> Answer.watch). The meter fit stays for speed at 1-3 s
meters, but counts only when an ask was slow to start and no battery
fit said on time. Late from 9.5 s after 2 fits, on time under 6.5 s,
dropped after 120 s without a fit. Fits are bounded (a few ms).
Readings on time: no verdict in any reviewer scenario (22 shapes at
1/2/5 s meters, 7 harm runs) nor in 2 days of the recorded house at
1, 2 and 5 s ticks; digests equal to 77fe31b. Late: found 36-68 s after
the start at 1-2 s, about 3 min at 5 s. The row gives the batteries'
delay, or no figure while only the meter has shown it.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
SAFETY-128 round 2 (head d3f4f9f): ready for a re-review. CI run 1511 checks / unit (pull_request) is green.
B1: done. Late readings are now found mainly from each battery's reading against its own power request (lateness.Delay, fed every 1 s from read_inputs). The house does not move that reading. The meter fit stays only for speed at 1-3 s meters, and it counts only when a request was slow to start moving a reading (3 s or more), no battery gave nothing under it, and no battery fit in the last 60 s said on time.
Your fp.py: 22 no-lag scenarios and 7 harm runs give 0 s late, 0 episodes and 0 W command difference against no gate.
Recorded house: 0 late cycles at 1, 2 and 5 s ticks (2-3 days), on modes_off, own_fresh_state_epex and n1_modes_off. All 7 cases at 5 s.
sha is identical to 77fe31b everywhere. The detector ran: 3 132 to 15 311 fits per case.
B2: done. The battery fit runs at any meter speed. A 5 s meter, 30 s late, is found after about 3 min and the export is cut to a third (-1 325 -> -420 W). At 1-2 s, 10/30 s late is found in 36-68 s.
M1: done. Late on 2 battery fits in a row from 9.5 s, on time under 6.5 s, dropped after 120 s without a fit. 30 s late for 300 s gives 1 episode and 1 end.
M2: done. History is bounded to 60 s of readings plus 90 s of requests. Worst watch() after 3 idle days: 9.5 ms on x86.
Minors: done.
The row gives the batteries' fitted delay, or no figure while only the meter has shown it.
The WARNING says "the batteries".
The DECISIONS line now states the evidence.
Mutants: 10, all red, over test_safety128.py (61 checks).
Local suite: 94 files, 0 failed.
Not changed, as briefed: the ping-pong and charge_room_w(). Follow-up cards.
**SAFETY-128 round 2 (head d3f4f9f): ready for a re-review.** CI run 1511 `checks / unit (pull_request)` is green.
- **B1: done.** Late readings are now found mainly from each battery's reading against its own power request (`lateness.Delay`, fed every 1 s from `read_inputs`). The house does not move that reading. The meter fit stays only for speed at 1-3 s meters, and it counts only when a request was slow to start moving a reading (3 s or more), no battery gave nothing under it, and no battery fit in the last 60 s said on time.
- Your `fp.py`: 22 no-lag scenarios and 7 harm runs give 0 s late, 0 episodes and 0 W command difference against no gate.
- Recorded house: 0 late cycles at 1, 2 and 5 s ticks (2-3 days), on modes_off, own_fresh_state_epex and n1_modes_off. All 7 cases at 5 s.
- sha is identical to 77fe31b everywhere. The detector ran: 3 132 to 15 311 fits per case.
- **B2: done.** The battery fit runs at any meter speed. A 5 s meter, 30 s late, is found after about 3 min and the export is cut to a third (-1 325 -> -420 W). At 1-2 s, 10/30 s late is found in 36-68 s.
- **M1: done.** Late on 2 battery fits in a row from 9.5 s, on time under 6.5 s, dropped after 120 s without a fit. 30 s late for 300 s gives 1 episode and 1 end.
- **M2: done.** History is bounded to 60 s of readings plus 90 s of requests. Worst `watch()` after 3 idle days: 9.5 ms on x86.
- **Minors: done.**
- The row gives the batteries' fitted delay, or no figure while only the meter has shown it.
- The WARNING says "the batteries".
- The DECISIONS line now states the evidence.
- **Mutants:** 10, all red, over test_safety128.py (61 checks).
- **Local suite:** 94 files, 0 failed.
- **Not changed, as briefed:** the ping-pong and `charge_room_w()`. Follow-up cards.
Details: reports/SAFETY-128-dev-report.md, section "Round 2".
Re-review of d3f4f9f (SAFETY-128 round 2): approve. B1, B2, M1 and M2 from round 1 are fixed.
I pushed one doc-only commit, 7de77a5: the DECISIONS slow-battery lines did not match the measured runs (finding 1 below). CI run 1513 checks / unit is green on 7de77a5, so I am merging it.
What I re-ran myself (real cycle(), no lag, gate against no gate)
My round-1 set, 18 no-lag scenarios:
owner shape at 5/2/1 s, N=1 and N=2;
clouds; 300 W/s battery; jitter 0-4 s; EV following the meter; capped batteries; HA restart; meter pause; 6 s dead time.
Result: 0 s late, 0 W command difference in every one. Battery fits ran 31-1 589 times per run. The meter fit said late up to 14 times per run, and the corroboration rejected every one.
Harm runs: ovens 45/60, 30/30, 60/60, 20/40 at 1/2/5 s, and the owner shape. Gate and no gate are identical.
Reversal dead time of 6 s and 10 s, solar/load flips at 1/2/5 s: 0 late.
Recorded house, no lag: head and base sha are identical with the detector running, late on 0 cycles:
Local suite on d3f4f9f: 94 files, 0 failed, 2 210 s (goodwe-addon-checks:1, --network none, hwsim mounted). CI run 1513 on 7de77a5: success.
Findings
Medium (doc fixed in 7de77a5, behaviour is a follow-up): a slow battery is read as "Home Assistant late".
Battery
Meter
late_seen
8 s dead time
any
none (reads 7-8 s)
10 s dead time
1-2 s
474-2 610 s of 3 300
10 s dead time
5 s
none (reads 9 s, margin 0.5 s)
12 s dead time
1 s and 5 s
late
first-order lag 15-20 s
1-5 s
102-664 s, 3-9 episodes
6 s dead + 3 s lag + 10 s reversal dead time, N=1
2 s
16 s
Harm (gate against no gate):
With a dead time, the hold mostly helps. Import was 309 against 602 Wh and 300 against 793 Wh. The highest 60 s grid average was worse in one run: 1 579 against 1 194 W.
With a 15-20 s lag, the hold makes it a little worse. At 20 s and a 1 s meter: export 155 against 111 Wh, highest 60 s average 742 against 591 W, steps over 500 W 231 against 78. No run reached 0 W.
The GoodWe as measured settles in 6 s (firmware has no filter on 0x0518; live answers 1.4-11 s, publish-quantized). The rig battery with that shape reads 0-4 s, which leaves about 6 s of margin. Not a blocker for GoodWe sites; a follow-up card.
Minor: the NL row "tonen vermogensvraag te laat" is not good Dutch (no article). "readings show requests late" is not very plain either. A UX follow-up.
The dev's named limits
1 s meter, 10 s late: -422..340 W (base -1 557 W). Outside the brief's 250 W / -200 W, but 3-4x better than today. Acceptable for this PR; follow-up card.
5 s meter, 30 s late: found after ~3 min, export -420 W (base -1 325 W). Better than today, with no false holds. It is the owner's meter, so it needs a follow-up card, not a block.
Ping-pong and charge_room_w(): unchanged; follow-up cards, as before.
**Re-review of d3f4f9f (SAFETY-128 round 2): approve.** B1, B2, M1 and M2 from round 1 are fixed.
I pushed one doc-only commit, **7de77a5**: the DECISIONS slow-battery lines did not match the measured runs (finding 1 below). CI run 1513 `checks / unit` is green on 7de77a5, so I am merging it.
### What I re-ran myself (real `cycle()`, no lag, gate against no gate)
- **My round-1 set, 18 no-lag scenarios:**
- owner shape at 5/2/1 s, N=1 and N=2;
- clouds; 300 W/s battery; jitter 0-4 s; EV following the meter; capped batteries; HA restart; meter pause; 6 s dead time.
- Result: **0 s late, 0 W command difference** in every one. Battery fits ran 31-1 589 times per run. The meter fit said late up to 14 times per run, and the corroboration rejected every one.
- **Harm runs:** ovens 45/60, 30/30, 60/60, 20/40 at 1/2/5 s, and the owner shape. Gate and no gate are identical.
- **Reversal dead time** of 6 s and 10 s, solar/load flips at 1/2/5 s: 0 late.
- **Recorded house, no lag:** head and base sha are identical with the detector running, late on 0 cycles:
- 5 s, 3 days: modes_off c1b230c2, arb_epex e5230acc, own_fresh_state_epex 558439f3, n1 80d42c7d (3 598-5 384 battery fits);
- 2 s, 2 days: modes_off cae86deb, own 14f1204b, n1 9dc58905;
- 1 s, 2 days: modes_off d849afb3, own 922ecf86.
- **Mutants I rebuilt from head:** no loop hold (9 fails), no battery-fresh veto on the meter (2), `ON_FITS` 1 (1). All red.
- **CPU:** worst `watch()` 6-13 ms whatever the idle time before (x86). Bounded.
- **CI:** run 1511 on d3f4f9f is a real success.
- **Local suite on d3f4f9f:** 94 files, 0 failed, 2 210 s (goodwe-addon-checks:1, --network none, hwsim mounted). CI run 1513 on 7de77a5: success.
### Findings
1. **Medium (doc fixed in 7de77a5, behaviour is a follow-up): a slow battery is read as "Home Assistant late".**
| Battery | Meter | late_seen |
|---|---|---|
| 8 s dead time | any | none (reads 7-8 s) |
| 10 s dead time | 1-2 s | 474-2 610 s of 3 300 |
| 10 s dead time | 5 s | none (reads 9 s, margin 0.5 s) |
| 12 s dead time | 1 s and 5 s | late |
| first-order lag 15-20 s | 1-5 s | 102-664 s, 3-9 episodes |
| 6 s dead + 3 s lag + 10 s reversal dead time, N=1 | 2 s | 16 s |
Harm (gate against no gate):
- With a dead time, the hold mostly helps. Import was 309 against 602 Wh and 300 against 793 Wh. The highest 60 s grid average was worse in one run: 1 579 against 1 194 W.
- With a 15-20 s lag, the hold makes it a little worse. At 20 s and a 1 s meter: export 155 against 111 Wh, highest 60 s average 742 against 591 W, steps over 500 W 231 against 78. No run reached 0 W.
The GoodWe as measured settles in 6 s (firmware has no filter on 0x0518; live answers 1.4-11 s, publish-quantized). The rig battery with that shape reads 0-4 s, which leaves about 6 s of margin. Not a blocker for GoodWe sites; a follow-up card.
2. **Minor:** the NL row "tonen vermogensvraag te laat" is not good Dutch (no article). "readings show requests late" is not very plain either. A UX follow-up.
### The dev's named limits
- **1 s meter, 10 s late: -422..340 W** (base -1 557 W). Outside the brief's 250 W / -200 W, but 3-4x better than today. Acceptable for this PR; follow-up card.
- **5 s meter, 30 s late: found after ~3 min, export -420 W** (base -1 325 W). Better than today, with no false holds. It is the owner's meter, so it needs a follow-up card, not a block.
- **Ping-pong and `charge_room_w()`:** unchanged; follow-up cards, as before.
admin
merged commit 371961e51c into release/1.02026-11-02 05:49:08 +01: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.
SAFETY-128: control does not act on late Home Assistant readings.
Problem (SAFETY-127 report): readings that Home Assistant delivers late but still changing pass every staleness gate. The loop corrects, each cycle, an error its last command already fixed, and the give run swings with growing export (10 s late: commands -450..+3 790 W, grid to -1.9 kW). The peak guard pushed to the inverter limit on the same late figures (OPT-34 F-2).
Design (
app/lateness.py, DECISIONS/SAFETY-128-late-readings.md):Delayfits, every 4 s over the last 40 s of new meter values, how long after a power request the meter shows it (meter(t) + request(t - d)is flattest at the rightd). OverDELAY_S= 8 s the readings are late. Guards against false positives: the requests must explain half the meter's swing; a latedmust fit 30 % better than any fresh one and better than a slow battery (10/20 s lag) answering at once; only new meter values count (a silent meter is not late); a battery giving nothing or refusing is left out.ANSWER_W= 100 W from the battery reading is pending until the reading answers it; meanwhile the loop may not lead the reading by more than 100 W that way (compute(hold=, hold_w=)) and the peak guard's floor is capped at what stands (peakguard.cap_floor()).LATE_S= 23 s (6 s dead time + 6 s settle + 10 s publish + 1 s read) with no reading moving towards it: hold, then 0 W afterstale_input_s("readings late").READINGS_LATEand one action-log line per episode.Tests:
test_safety128.py(realcycle(), TEST-23's plant intools/safety128_rig.py). Without the gate (=77fe31b) red: export below -1 kW and a growing swing at 10 s and 30 s late; the guard at 9 600 W in a quarter's last 30 s. With it green: within 250 W of the last good command, no export below -200 W, settled at once after the lag; the guard never at the limit. No-lag runs at 1/2/5 s meters, a 6 s dead time, start-up, cut-out level, a refusing battery, a 10/20 s slow battery: same commands, never taken for late.Changed existing tests:
test_peakguard.pycounts 18 direct claim appends (the new "readings late" claim);test_safety94.pyandtest_safety96.pytake the new gate out of their "before the fix" runs too.Mutations, graded over test_safety128.py: no loop hold, no peak cap, never late, hold ignores exempt, no 0 W claim, no slow-battery check: all red.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Review of
09e1fbb(SAFETY-128): changes requested. Not merged.CI run 1501
checks / unit (pull_request)is green on09e1fbb(checked through the API). The local suite result is below. Mutants 1, 2, 5 and 6 rebuilt by me from head: all red. Digests modes_off, arb_epex, own_fresh_state_epex and n1_modes_off at the replay's 5 s tick: base and head identical (sha c1b230c2, e5230acc, 558439f3, 80d42c7d). But see B2: at 5 s the detector never runs, so these digests do not test it.Blockers
B1. False positives on healthy sites with a 1 s or 2 s meter.
app/lateness.py:96-123. All runs below go through the realcycle()(tools/safety128_rig.py plant) with no lag:Harm while held on a healthy site (oven 45/60, 2 s, N=2, gate against no gate):
The replays change commands too: 49 986 against 49 808 set_number calls, different sha.
Cause: the fit assumes the house is flat in the 40 s window. But the loop's requests echo the house steps it chases. When the house steps inside the window, the true delay fails
FIT_SHARE, and an earlier echo of a repeating load lines up as a "late" delay.FIT_BETTERonly compares againstd <= 8, and those delays are poor fits in exactly that case. Repeating loads (an oven, a hob or a heat pump cycling) are normal in houses.B2. With a 5 s meter the detector never runs.
app/lateness.py:86,91:len(rows) < 10withFIT_S40 s allows at most 9 new values at 5 s. The owner's P1 is polled every 5 s, andcontrol.py:495names 5 s as the reference meter. Rig runs at a 5 s meter, N=2, house 1 800 W:So the fix does nothing on the reference setup. The dev report's "5 s meter: 0 W difference" check and the 5 s digests (0 fits in 3 days) prove that it is inert, not that it is correct. Neither DECISIONS nor the report states this limit.
Major
late_seen(lateness.py:189-191,main.py:3137-3149). The verdict flips on every 4 s fit. Each flip logs one WARNING and one action-log line, so 14-17 WARNINGs came in 20 min in the runs above. "One per episode" holds only because each episode is short.lateness.py:114-120). It simulates every second fromcmds[0], which can be hours old after a long constant command. Worstestimate()on this x86 host: 3 ms normally, 110 ms after 1 day idle, 315 ms after 3 days. On a Pi that blocks the event loop for seconds, once every 4 s, for about 130 s. Starting atrows[0][0] - MAX_DELAY_Sbounds it.Minor
The limits the dev names
charge_room_w()swing (-8 678 W at 10 s late): neither is a blocker for this PR, but both need cards.Not changed by me
test_peakguard.py17 -> 18 counts the new claim. That is honest.test_safety94/96.py: switching the gate off in the "before" counterfactual is honest too, since that tree had no gate. But it shows the detector fires on a controller swinging against a slow battery with readings on time, which is the same weakness as B1.Needed before merge: a detector with no late verdict in the B1 scenarios and in a recorded-house replay at 1 s and 2 s meters, one that works at a 5 s meter (or the limit stated and accepted by the owner), hysteresis, and a bounded slow-battery check.
SAFETY-128 round 2 (head
d3f4f9f): ready for a re-review. CI run 1511checks / unit (pull_request)is green.lateness.Delay, fed every 1 s fromread_inputs). The house does not move that reading. The meter fit stays only for speed at 1-3 s meters, and it counts only when a request was slow to start moving a reading (3 s or more), no battery gave nothing under it, and no battery fit in the last 60 s said on time.fp.py: 22 no-lag scenarios and 7 harm runs give 0 s late, 0 episodes and 0 W command difference against no gate.77fe31beverywhere. The detector ran: 3 132 to 15 311 fits per case.watch()after 3 idle days: 9.5 ms on x86.charge_room_w(). Follow-up cards.Details: reports/SAFETY-128-dev-report.md, section "Round 2".
Re-review of
d3f4f9f(SAFETY-128 round 2): approve. B1, B2, M1 and M2 from round 1 are fixed.I pushed one doc-only commit,
7de77a5: the DECISIONS slow-battery lines did not match the measured runs (finding 1 below). CI run 1513checks / unitis green on7de77a5, so I am merging it.What I re-ran myself (real
cycle(), no lag, gate against no gate)ON_FITS1 (1). All red.watch()6-13 ms whatever the idle time before (x86). Bounded.d3f4f9fis a real success.d3f4f9f: 94 files, 0 failed, 2 210 s (goodwe-addon-checks:1, --network none, hwsim mounted). CI run 1513 on7de77a5: success.Findings
Medium (doc fixed in
7de77a5, behaviour is a follow-up): a slow battery is read as "Home Assistant late".Harm (gate against no gate):
The GoodWe as measured settles in 6 s (firmware has no filter on 0x0518; live answers 1.4-11 s, publish-quantized). The rig battery with that shape reads 0-4 s, which leaves about 6 s of margin. Not a blocker for GoodWe sites; a follow-up card.
Minor: the NL row "tonen vermogensvraag te laat" is not good Dutch (no article). "readings show requests late" is not very plain either. A UX follow-up.
The dev's named limits
charge_room_w(): unchanged; follow-up cards, as before.