SAFETY-125 (hotfix 0.19.2). Base hotfix-0.19.2 = master 2932f3a (v0.19.1).
What the live night showed, and what was wrong. The 00:15-00:30 quarter did not go above the month peak (it stayed 5 413 W). The guard cut the cheap run late in the quarter, as designed: the charge is sized to the quarter's room, and the room shrinks as the early high draw is counted. The live row was false, though: "Peak guard: idle, heading for 5 814 W, under this month's 5 413 W peak". The sim of the live shape (A charging about 11 % harder than asked, as measured live) reproduces both the trace (grid 4 924 W at 00:27:50, 3 860 W at 00:29:50) and the row, and lands the quarter at 5 367.6 W (aim 5 363 W).
The breach that is real. A meter reading late by 10-30 s near a quarter's end. tariff.project() leaves the seconds since the last reading out until the next reading lands (SAFETY-99), so charge_headroom_w() reads them as room; near the end 900 / left turns that into thousands of W, and the cheap run goes back to -5 000 W for the last seconds. On 0.19.1, with a 30 s late reading at each quarter end: month peak banked 5 413 W -> the quarter lands at 5 419.5 W (over the month peak); no peak banked -> 3 519.7 W (over the 3 500 W cap); price trading with modes off -> 5 479.3 W / 3 564.7 W; maintenance at N=2 -> 5 380.8 W / 3 476.8 W. The bill counts those seconds; the add-on's bank did not (it banked 5 374 W).
Fix.
peakguard.charge_room_w() = charge_headroom_w() less held_w x late seconds / seconds left. Used for the mode trade and price trading (main.py, arb_headroom_w) and the N>=2 maintenance charge (_charge_headroom()). The guard's own floor (decide()) is unchanged; SAFETY-99's discharge side stays as it is.
Words: over the limit at the current rate, the row says "Peak guard: cutting the grid charge, at this rate 5 814 W, over this month's 5 413 W peak" (or "idle, ... over ..." when no grid charge is sized), and the month-peak line "At this rate this 15-minute period reaches 5.8 kW. The grid charge is cut back to stay under 5.4 kW." Under the limit both read exactly as in 0.19.1. NL too.
0.19.2 in config.yaml, CHANGELOG, DECISIONS note.
Tests.test_safety125.py (new). Mutations (tools/mutation_run.sh, purged copies) all caught: fix reverted 13 failed; trade path reverted 9; maintenance path reverted 2; row "over" branch removed 5; line "over" branch removed 3; mode-trade flag never set 4; row without the flag 2.
Existing tests changed (needs your eye).
test_arbitrage.py part 3b: the AST pin of self.arb_headroom_w's expression follows the new call.
test_price_run_fixes.py part 5: the two lag pins (n1_lag, n2_lag) move. Their P1 sample is 0.35 s old on every cycle; those seconds now count, so 16 of 1 080 cycles charge about 1.7 W less (true quarter 2 147.6 -> 2 147.4 W, under the aim). n1 and n2 still equal e3e4ba6.
Digests. TARIFF-24's replay and gate digests (1-day opt11p3 replay, the 4 real-gate runs, the 13 3-day opt11p4 runs): identical to base, line for line. Lagged replay (OPT-15 Lag, 5 s cycle): modes off, Leftover solar and Cheap hours identical; arb_only changes, its charge quarters over the aim go from [0.4, 0.9, 0.9, 1.2, 1.8, 21.5] W to [0.4, 20.1] W.
Not touched: SAFETY-80, SAFETY-111 (maintenance_charge_floor(), shaping_suspended(), peak_aim_w()), the saturation freeze, OPT-25's run sizing.
SAFETY-125 (hotfix 0.19.2). Base `hotfix-0.19.2` = master 2932f3a (v0.19.1).
**What the live night showed, and what was wrong.** The 00:15-00:30 quarter did not go above the month peak (it stayed 5 413 W). The guard cut the cheap run late in the quarter, as designed: the charge is sized to the quarter's room, and the room shrinks as the early high draw is counted. The live row was false, though: "Peak guard: idle, heading for 5 814 W, under this month's 5 413 W peak". The sim of the live shape (A charging about 11 % harder than asked, as measured live) reproduces both the trace (grid 4 924 W at 00:27:50, 3 860 W at 00:29:50) and the row, and lands the quarter at 5 367.6 W (aim 5 363 W).
**The breach that is real.** A meter reading late by 10-30 s near a quarter's end. `tariff.project()` leaves the seconds since the last reading out until the next reading lands (SAFETY-99), so `charge_headroom_w()` reads them as room; near the end `900 / left` turns that into thousands of W, and the cheap run goes back to -5 000 W for the last seconds. On 0.19.1, with a 30 s late reading at each quarter end: month peak banked 5 413 W -> the quarter lands at 5 419.5 W (over the month peak); no peak banked -> 3 519.7 W (over the 3 500 W cap); price trading with modes off -> 5 479.3 W / 3 564.7 W; maintenance at N=2 -> 5 380.8 W / 3 476.8 W. The bill counts those seconds; the add-on's bank did not (it banked 5 374 W).
**Fix.**
- `peakguard.charge_room_w()` = `charge_headroom_w()` less `held_w x late seconds / seconds left`. Used for the mode trade and price trading (`main.py`, `arb_headroom_w`) and the N>=2 maintenance charge (`_charge_headroom()`). The guard's own floor (`decide()`) is unchanged; SAFETY-99's discharge side stays as it is.
- Words: over the limit at the current rate, the row says "Peak guard: cutting the grid charge, at this rate 5 814 W, over this month's 5 413 W peak" (or "idle, ... over ..." when no grid charge is sized), and the month-peak line "At this rate this 15-minute period reaches 5.8 kW. The grid charge is cut back to stay under 5.4 kW." Under the limit both read exactly as in 0.19.1. NL too.
- 0.19.2 in `config.yaml`, CHANGELOG, DECISIONS note.
**Tests.** `test_safety125.py` (new). Mutations (tools/mutation_run.sh, purged copies) all caught: fix reverted 13 failed; trade path reverted 9; maintenance path reverted 2; row "over" branch removed 5; line "over" branch removed 3; mode-trade flag never set 4; row without the flag 2.
**Existing tests changed (needs your eye).**
- `test_arbitrage.py` part 3b: the AST pin of `self.arb_headroom_w`'s expression follows the new call.
- `test_price_run_fixes.py` part 5: the two lag pins (`n1_lag`, `n2_lag`) move. Their P1 sample is 0.35 s old on every cycle; those seconds now count, so 16 of 1 080 cycles charge about 1.7 W less (true quarter 2 147.6 -> 2 147.4 W, under the aim). `n1` and `n2` still equal e3e4ba6.
**Digests.** TARIFF-24's replay and gate digests (1-day opt11p3 replay, the 4 real-gate runs, the 13 3-day opt11p4 runs): identical to base, line for line. Lagged replay (OPT-15 Lag, 5 s cycle): modes off, Leftover solar and Cheap hours identical; `arb_only` changes, its charge quarters over the aim go from [0.4, 0.9, 0.9, 1.2, 1.8, 21.5] W to [0.4, 20.1] W.
Not touched: SAFETY-80, SAFETY-111 (`maintenance_charge_floor()`, `shaping_suspended()`, `peak_aim_w()`), the saturation freeze, OPT-25's run sizing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
A meter reading late near a quarter's end left seconds the projection did
not count (SAFETY-99), and charge_headroom_w() read them as room: the cheap
run (and price trading, and the N=2 maintenance charge) went back to full
power in the quarter's last seconds and the quarter could land over a banked
month peak or the cap. peakguard.charge_room_w() counts those seconds at the
held reading for the charge only; the guard's floor is unchanged.
The guard row said "idle, heading for 5 814 W, under this month's 5 413 W
peak". Over the limit at the current rate the row and the month-peak line
now say so, and that the grid charge is cut back.
Version 0.19.2, CHANGELOG, DECISIONS. test_safety125.py new; the lag pins
in test_price_run_fixes.py and test_opt20.py and the AST pin in
test_arbitrage.py follow the change (see the pull request).
Co-Authored-By: Claude <noreply@anthropic.com>
- The over-the-limit rows ran over STYLE's 15 words (EN 16/19, NL 16/21)
and two Dutch Home sentences over 12 words; they were not in the samples,
so test_messages/test_ux46 never saw them. Now:
"Peak guard: grid charge limited, at this rate 5 814 W, over 5 413 W" /
"Piekbewaking: laden beperkt, aan dit tempo 5 814 W, boven 5 413 W",
"...idle, at this rate ..., over ..." / "...in rust, aan dit tempo ...",
NL Home "Laadt minder van het net om onder 5,4 kW te blijven." and
"Zou minder van het net laden om onder 5,4 kW te blijven."
The rows and the line are added to the samples. The under row is unchanged.
- CHANGELOG known issues and the DECISIONS note: at N=1 the maintenance
charge is not sized to the room, so SAFETY-99 still applies to it.
- DOCS: the peak guard row's new words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SAFETY-125 review (Opus), head reviewed 88ab8a5; text fixes pushed as 6eebd48. Verdict: not merged. One finding needs a behaviour change (F1), so it goes back to the manager and the owner.
What holds
Root cause confirmed on 2932f3a. I used my own harness: the real Controller, the live shape, a 1 s cycle with OPT-15's lag, and the true grid integrated per sub-step. With the last 30 s of each quarter silent, the quarter lands at 5 419.6 W (above the 5 413 W peak) and 3 519.7 W with no peak banked. With modes off and price trading, it lands at 5 479.4 W. The cause is tariff.pyproject() together with peakguard.charge_headroom_w(): the seconds since the last reading count as room, and 900/left makes them large. The forecast entity changes nothing, in kW or in W.
The fix is exact.charge_room_w() equals charge_headroom_w() of the projection that counts the late seconds at the held reading (20 000 random cases, 0 differ). It stays finite near 0 s left, passes through None at 0 s left and past the end, and gives 0 W when the guard is blind. fill_gaps() runs first, so gap and unseen seconds are not counted twice. decide(), charge_headroom_w(), the discharge side and the saturation freeze are untouched.
On head, these land at or under the aim: the cheap run, Leftover solar and price trading, at N=1 and N=2, with the reading 10/15/20/30/45 s late, through a restart at 00:07 and across the 2026-10-25 DST change. The charge comes back in the next quarter's first minute. There is less churn than on base: steps over 200 W, 46 min, 10 s meter, go from 126 to 41; price trading from 235 to 86.
Digests:digest.py and digest2.py are identical to base and to the dev's (their meters have no late seconds). In digest3.py only arb_only changes, as the dev reported.
Mutations: the dev's #1, #3 and #6 are all caught (13 / 2 / 4 fail). My own mutation late_ws / QUARTER_S is caught (13 fail).
Full suite in goodwe-addon-checks:1 (--network none, hwsim): 85/85 on 88ab8a5 and 85/85 on 6eebd48. CI runs 1426 and 1427 on 88ab8a5 are green (85 PASS, test_freeze on the rig hwsim).
The version is 0.19.2 in config.yaml, the same single place the 0.19.1 hotfix changed.
Findings
F1 (High for the product; not a regression; blocks the merge): the maintenance charge at N=1 does not use charge_room_w().app/main.py:3239: _charge_headroom() returns floor at N<2. So the N=1 cheap-window charge still runs at charge_w until the guard engages. A late reading releases it again (SAFETY-99). I measured it on the safety84 rig (5 s cycle). Base and head give the same numbers:
month peak 5 413 W, house 4 kW:
healthy meter: 5 374.5 W (+11 over the aim);
reading 30 s late: 5 423.6 W in the first quarter, then 5 405.8 W;
10 s meter: 5 453.4 W and 5 429.0 W, above the month peak.
no peak banked, house 2.5 kW:
10 s meter: 3 505.5 W;
10 s meter and 20 s late: 3 597.0 W and 3 595.7 W, above the cap.
Experiment, not pushed: without the N<2 early return, all 8 N=1 cases land on the aim (steady 5 363.0 / 3 450.0 W; worst +13.6 W in the first quarter).
This is a behaviour change at N=1. SAFETY-84 calls it "an owner question". Either extend the fix to N=1, or the owner accepts it as known (SAFETY-99) and 6eebd48 is merged.
F2 (Medium, fixed in 6eebd48): the new strings broke UX-08, and the gates did not see them. In app/messages.py:1173, the rows ran over 15 words: EN 16/19 and NL 16/21. Two Dutch Home sentences ran over 12 words (ui_text/nl.json:355-356). None of them was in status_samples() / text_samples(). Old and new text:
"Peak guard: cutting the grid charge, at this rate X, over this month's Y peak" -> "Peak guard: grid charge limited, at this rate X, over Y"
"Piekbewaking: schroeft het laden van het net terug, aan dit tempo X, boven de maandpiek van Y" -> "Piekbewaking: laden beperkt, aan dit tempo X, boven Y"
"Peak guard: idle, at this rate X, over this month's Y peak" -> "Peak guard: idle, at this rate X, over Y" (NL likewise)
"Het laden van het net wordt teruggeschroefd om onder {kw} te blijven." -> "Laadt minder van het net om onder {kw} te blijven." (the same shape as peak_what)
"Het laden van het net zou teruggeschroefd worden om onder {kw} te blijven." -> "Zou minder van het net laden om onder {kw} te blijven."
The under row is unchanged. All of them are now in the samples. test_safety125.py follows.
F3 (Low, fixed in 6eebd48): the docs did not match the code. The CHANGELOG known-issues line said SAFETY-99 "now covers the guard's discharge only, not a grid charge". That is false at N=1 (F1). It now reads: "...covers the guard's discharge, and the maintenance charge with one inverter." The DECISIONS note says the same. The DOCS status table explains the new row words.
F4 (Low): test gaps.
Two of my mutations survive test_safety125.py:
drop self.room_charge = ... in _charge_headroom() (main.py:3246): no word check during an N=2 maintenance charge;
cut=self.room_charge without and not self.shadow (main.py:7083): no shadow-mode row check.
The file does not cover these edge cases: about 0 s left, 0 s left, a blind guard in a controller run, a restart mid-quarter, DST, the forecast in W, and a slow 10 s meter. I covered them in review scripts (scratch/SAFETY-125/review/).
F5 (Info): residuals and the quarters that change while under the aim. All are explained, none goes above the peak or the cap.
10 s meter, steady quarters: +6.8 W with modes (base +30), +2.5 W with price trading (base +29).
First quarter: +15.2 / +20.7 W (base +30.8 / +41.3).
digest3 arb_only: the trade's first quarter (13:00 UTC) is +20.1 W, against +21.5 W on base, an onset overshoot that was already there.
N=2 maintenance on a 10 s meter: head lands on the aim (5 363.0 W) where base landed under it (5 344.0 W). Base's inflated room made the guard engage and stand the charge down. The first quarter goes from 5 360.4 to 5 376.5 W (+13.5 over the aim, 37 W under the peak).
The n1_lag / n2_lag pins: confirmed. 16 of 1 080 cycles charge 1.7-2.0 W less, and the quarter goes from 2 147.56 to 2 147.41 W.
ch_ch: 3 451.3-3 451.4 W -> 3 450.0 W.
No "late by more than N s" threshold: I agree. The correction is an exact identity, and a threshold would leave the slow meter open.
Details: reports/SAFETY-125-review-report.md.
**SAFETY-125 review (Opus), head reviewed 88ab8a5; text fixes pushed as 6eebd48.** Verdict: **not merged**. One finding needs a behaviour change (F1), so it goes back to the manager and the owner.
**What holds**
- **Root cause confirmed on 2932f3a.** I used my own harness: the real Controller, the live shape, a 1 s cycle with OPT-15's lag, and the true grid integrated per sub-step. With the last 30 s of each quarter silent, the quarter lands at 5 419.6 W (above the 5 413 W peak) and 3 519.7 W with no peak banked. With modes off and price trading, it lands at 5 479.4 W. The cause is `tariff.py` `project()` together with `peakguard.charge_headroom_w()`: the seconds since the last reading count as room, and `900/left` makes them large. The forecast entity changes nothing, in kW or in W.
- **The fix is exact.** `charge_room_w()` equals `charge_headroom_w()` of the projection that counts the late seconds at the held reading (20 000 random cases, 0 differ). It stays finite near 0 s left, passes through None at 0 s left and past the end, and gives 0 W when the guard is blind. `fill_gaps()` runs first, so gap and unseen seconds are not counted twice. `decide()`, `charge_headroom_w()`, the discharge side and the saturation freeze are untouched.
- **On head, these land at or under the aim:** the cheap run, Leftover solar and price trading, at N=1 and N=2, with the reading 10/15/20/30/45 s late, through a restart at 00:07 and across the 2026-10-25 DST change. The charge comes back in the next quarter's first minute. There is less churn than on base: steps over 200 W, 46 min, 10 s meter, go from 126 to 41; price trading from 235 to 86.
- **Digests:** `digest.py` and `digest2.py` are identical to base and to the dev's (their meters have no late seconds). In `digest3.py` only arb_only changes, as the dev reported.
- **Mutations:** the dev's #1, #3 and #6 are all caught (13 / 2 / 4 fail). My own mutation `late_ws / QUARTER_S` is caught (13 fail).
- **Full suite** in `goodwe-addon-checks:1` (`--network none`, hwsim): 85/85 on 88ab8a5 and 85/85 on 6eebd48. CI runs 1426 and 1427 on 88ab8a5 are green (85 PASS, `test_freeze` on the rig hwsim).
- The version is 0.19.2 in `config.yaml`, the same single place the 0.19.1 hotfix changed.
**Findings**
- **F1 (High for the product; not a regression; blocks the merge): the maintenance charge at N=1 does not use `charge_room_w()`.** `app/main.py:3239`: `_charge_headroom()` returns `floor` at N<2. So the N=1 cheap-window charge still runs at `charge_w` until the guard engages. A late reading releases it again (SAFETY-99). I measured it on the safety84 rig (5 s cycle). Base and head give the same numbers:
- month peak 5 413 W, house 4 kW:
- healthy meter: 5 374.5 W (+11 over the aim);
- reading 30 s late: 5 423.6 W in the first quarter, then 5 405.8 W;
- 10 s meter: 5 453.4 W and 5 429.0 W, **above the month peak**.
- no peak banked, house 2.5 kW:
- 10 s meter: 3 505.5 W;
- 10 s meter and 20 s late: 3 597.0 W and 3 595.7 W, **above the cap**.
- Experiment, not pushed: without the N<2 early return, all 8 N=1 cases land on the aim (steady 5 363.0 / 3 450.0 W; worst +13.6 W in the first quarter).
- This is a behaviour change at N=1. SAFETY-84 calls it "an owner question". Either extend the fix to N=1, or the owner accepts it as known (SAFETY-99) and 6eebd48 is merged.
- **F2 (Medium, fixed in 6eebd48): the new strings broke UX-08, and the gates did not see them.** In `app/messages.py:1173`, the rows ran over 15 words: EN 16/19 and NL 16/21. Two Dutch Home sentences ran over 12 words (`ui_text/nl.json:355-356`). None of them was in `status_samples()` / `text_samples()`. Old and new text:
- "Peak guard: cutting the grid charge, at this rate X, over this month's Y peak" -> "Peak guard: grid charge limited, at this rate X, over Y"
- "Piekbewaking: schroeft het laden van het net terug, aan dit tempo X, boven de maandpiek van Y" -> "Piekbewaking: laden beperkt, aan dit tempo X, boven Y"
- "Peak guard: idle, at this rate X, over this month's Y peak" -> "Peak guard: idle, at this rate X, over Y" (NL likewise)
- "Het laden van het net wordt teruggeschroefd om onder {kw} te blijven." -> "Laadt minder van het net om onder {kw} te blijven." (the same shape as `peak_what`)
- "Het laden van het net zou teruggeschroefd worden om onder {kw} te blijven." -> "Zou minder van het net laden om onder {kw} te blijven."
The under row is unchanged. All of them are now in the samples. `test_safety125.py` follows.
- **F3 (Low, fixed in 6eebd48): the docs did not match the code.** The CHANGELOG known-issues line said SAFETY-99 "now covers the guard's discharge only, not a grid charge". That is false at N=1 (F1). It now reads: "...covers the guard's discharge, and the maintenance charge with one inverter." The DECISIONS note says the same. The DOCS status table explains the new row words.
- **F4 (Low): test gaps.**
- Two of my mutations survive `test_safety125.py`:
- drop `self.room_charge = ...` in `_charge_headroom()` (`main.py:3246`): no word check during an N=2 maintenance charge;
- `cut=self.room_charge` without `and not self.shadow` (`main.py:7083`): no shadow-mode row check.
- The file does not cover these edge cases: about 0 s left, 0 s left, a blind guard in a controller run, a restart mid-quarter, DST, the forecast in W, and a slow 10 s meter. I covered them in review scripts (`scratch/SAFETY-125/review/`).
- **F5 (Info): residuals and the quarters that change while under the aim. All are explained, none goes above the peak or the cap.**
- 10 s meter, steady quarters: +6.8 W with modes (base +30), +2.5 W with price trading (base +29).
- First quarter: +15.2 / +20.7 W (base +30.8 / +41.3).
- digest3 arb_only: the trade's first quarter (13:00 UTC) is +20.1 W, against +21.5 W on base, an onset overshoot that was already there.
- N=2 maintenance on a 10 s meter: head lands on the aim (5 363.0 W) where base landed under it (5 344.0 W). Base's inflated room made the guard engage and stand the charge down. The first quarter goes from 5 360.4 to 5 376.5 W (+13.5 over the aim, 37 W under the peak).
- The `n1_lag` / `n2_lag` pins: confirmed. 16 of 1 080 cycles charge 1.7-2.0 W less, and the quarter goes from 2 147.56 to 2 147.41 W.
- `ch_ch`: 3 451.3-3 451.4 W -> 3 450.0 W.
- No "late by more than N s" threshold: I agree. The correction is an exact identity, and a threshold would leave the slow meter open.
Details: `reports/SAFETY-125-review-report.md`.
CI on 6eebd48 (the review text fixes): run 1428 checks / unitsuccess, 85 PASS (test_freeze on the rig hwsim, infra 3b4ff84); run 1429 infra-pin / freshsuccess. Still not merged: F1 (the N=1 maintenance charge) needs an owner/manager decision. If F1 is accepted as known (SAFETY-99), 6eebd48 is ready to merge.
CI on 6eebd48 (the review text fixes): run 1428 `checks / unit` **success**, 85 PASS (test_freeze on the rig hwsim, infra 3b4ff84); run 1429 `infra-pin / fresh` **success**. Still **not merged**: F1 (the N=1 maintenance charge) needs an owner/manager decision. If F1 is accepted as known (SAFETY-99), 6eebd48 is ready to merge.
Owner 2026-10-29: fix N=1 in this hotfix. _charge_headroom() no longer
returns early at one inverter, so the N=1 cheap-window maintenance charge
is held to charge_room_w() as at N=2; SAFETY-84's site-stop rule stays N>=2
only (at N=1 such a stop outranks the shaping anyway). The guard no longer
steps in and gives power at each quarter's end during that charge.
test_safety125.py: N=1 maintenance on 5 s and 10 s meters with late
readings, with and without a banked peak; the row in an N=2 maintenance
charge and in shadow mode; edge cases (blind past the hold, a start
mid-quarter, the summer-time change, the forecast in W, a 10 s meter, the
last second). test_safety84/87: three N=1 pins move. test_safety119: its
quarter-edge hand-back runs keep the old N=1 sizing as a fixture, plus a
check that with the new sizing nothing hands back. CHANGELOG, DECISIONS,
DOCS follow.
Co-Authored-By: Claude <noreply@anthropic.com>
Round 2 (owner 2026-10-29: fix N=1 in this hotfix), head 2929ece on 6eebd48.
_charge_headroom() (main.py:3234-3247): the N=1 maintenance charge is now sized to charge_room_w() as at N=2. SAFETY-84's site-stop rule stays N>=2 only: at N=1 a stop outranks the shaping anyway, and there is no other pack to give. N=1 effects: the quarter lands on the aim, the guard no longer steps in or gives power at the quarter's end during the charge (engagements 24 -> 0 in the sweep), the battery charges every cycle at a lower rate, and the row can say "grid charge limited". Unchanged: no P1, outside a cheap window, HOLD/DRAIN, SAFETY-80/111 functions, N>=2.
Tests: test_safety125.py parts 6-8: N=1 on 5 s and 10 s meters with late readings, banked and not; the N=2 maintenance row and the shadow row (kill review mutations 9 and 10); edge cases. The N=1 tests fail on 6eebd48 (7). Mutations: N=1 reverted 7 failed; review 9 and 10, 1 failed each; round 1's fix reverted 22 failed.
Re-pinned (needs your eye): test_safety84n1_charge, n1_charge_banked; test_safety87n1_charge_low. test_safety119's quarter-edge runs keep the old N=1 sizing as a named fixture (the hand-back they grade no longer happens), plus a check that with the new sizing nothing hands back.
Digests vs base: digest.py, digest2.py identical; digest3.py as round 1. N=1 maintenance digests and the sweep table: in the dev report, Round 2.
Round 2 (owner 2026-10-29: fix N=1 in this hotfix), head 2929ece on 6eebd48.
- `_charge_headroom()` (`main.py:3234-3247`): the N=1 maintenance charge is now sized to `charge_room_w()` as at N=2. SAFETY-84's site-stop rule stays N>=2 only: at N=1 a stop outranks the shaping anyway, and there is no other pack to give. N=1 effects: the quarter lands on the aim, the guard no longer steps in or gives power at the quarter's end during the charge (engagements 24 -> 0 in the sweep), the battery charges every cycle at a lower rate, and the row can say "grid charge limited". Unchanged: no P1, outside a cheap window, HOLD/DRAIN, SAFETY-80/111 functions, N>=2.
- Tests: `test_safety125.py` parts 6-8: N=1 on 5 s and 10 s meters with late readings, banked and not; the N=2 maintenance row and the shadow row (kill review mutations 9 and 10); edge cases. The N=1 tests fail on 6eebd48 (7). Mutations: N=1 reverted 7 failed; review 9 and 10, 1 failed each; round 1's fix reverted 22 failed.
- Re-pinned (needs your eye): `test_safety84` `n1_charge`, `n1_charge_banked`; `test_safety87` `n1_charge_low`. `test_safety119`'s quarter-edge runs keep the old N=1 sizing as a named fixture (the hand-back they grade no longer happens), plus a check that with the new sizing nothing hands back.
- Digests vs base: `digest.py`, `digest2.py` identical; `digest3.py` as round 1. N=1 maintenance digests and the sweep table: in the dev report, Round 2.
- Local suite: 85/85. CI: pending on 2929ece.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
SAFETY-125 round 2 review (2929ece): stopped, not merged. CI checks / unit run 1430 (job 2580) was cut off by the runner, not by a test:
the goodwe-runner config has timeout: 60m (/home/homelab/docker/goodwe-runner/config.yaml);
task 2094 started at 13:57:45Z, and at 14:57:45Z, exactly 60 min later, the runner took the next task (2095, infra-pin);
the job log stops at 84/85 PASS. The last line is test_version_gate.py at 14:57:10Z; test_watchdog.py (68 s in earlier runs) has no result;
the run still shows in_progress, and the commit status is pending.
Round 1 ran 56-57 min. Round 2 adds about 4 min of tests, so every run of this branch now goes over the runner limit. No rerun, per the brief. The runner limit, or the suite time, needs a manager decision before this PR can get a real green run. The substantive round 2 review was not started past reading the diff and the dev report.
SAFETY-125 round 2 review (2929ece): **stopped, not merged.** CI `checks / unit` run 1430 (job 2580) was cut off by the runner, not by a test:
- the goodwe-runner config has `timeout: 60m` (`/home/homelab/docker/goodwe-runner/config.yaml`);
- task 2094 started at 13:57:45Z, and at 14:57:45Z, exactly 60 min later, the runner took the next task (2095, infra-pin);
- the job log stops at 84/85 PASS. The last line is `test_version_gate.py` at 14:57:10Z; `test_watchdog.py` (68 s in earlier runs) has no result;
- the run still shows `in_progress`, and the commit status is `pending`.
Round 1 ran 56-57 min. Round 2 adds about 4 min of tests, so every run of this branch now goes over the runner limit. No rerun, per the brief. The runner limit, or the suite time, needs a manager decision before this PR can get a real green run. The substantive round 2 review was not started past reading the diff and the dev report.
SAFETY-125 round 2 review (Opus), head 2929ece. Checks 1-8 were done offline. Verdict: ready to merge once a real green checks / unit run lands on 2929ece. Run 1430 ended as failure at 16:12 with no new test output after test_version_gate.py, 84/85 PASS: it was the stuck run cancelled after the runner was capped at 60 min, not a test failure. I did not rerun it.
The N=1 change (main.py:3234-3247) is right.
It matches my exp-n1.diff on all 16 maintenance cases (8 at N=1, 8 at N=2): the same quarters to 0.1 W, the N=1 runs land on the aim (worst +13.6 W in the first quarter on a 10 s meter), and the N=2 runs are unchanged from round 1.
Under a site stop at N=1 (auto off from 600 s to 900 s, cheap-window charge, peak banked): head and "the stop rule also at N=1" give the same command hash and the same claims. The stop holds 0 W, and the charge resumes at the room afterwards. Keeping the rule N>=2-only is right: there is no other pack to give.
The N=1 behaviour changes are intended and harmless. On the safety84 rig, N=1 now gives exactly N=2's numbers:
the guard still steps in when the house alone is over the aim.
Byte-identical to base: n1_charge_dear, every safety87 run except n1_charge_low (n1_under, n1_crossing, n1_above, n1_under_banked, n1_hold), and my N=1 runs: drain in a cheap window, drain outside one, hold in a cheap window.
SAFETY-80/111 functions are not touched; SAFETY-84 at N>=2 is unchanged.
The re-pins are honest.
n1_charge and n1_charge_banked now equal the N=2 runs of the same scenario.
n1_charge_low: quarters 3 452.2 / 3 456.7 W (over the 3 450 W aim) -> 3 450.3 / 3 450.0 W; guard engaged 277 -> 126 cycles.
The SAFETY-119 fixture still grades the shipped settle code on the hand-back shape it was built for. That shape now only comes from the old N=1 sizing, and the fixture names that. A new check shows that the shipped sizing gives no hand-back and no warning. Part 4 (N=2) uses the shipped code throughout.
mine, N=1 sized with charge_headroom_w(), so late seconds are ignored at N=1: 4 fail.
Digests vs base:digest.py and digest2.py are identical (all 13 digest2.py runs). digest3.py is the same as round 1. The N=1 maintenance digests (above) change only where a quarter was over the aim, or where the guard's discharge at the quarter's end had pushed it under the aim. That discharge is the behaviour being removed.
Text: true to the code. One Low nit, not pushed so I don't restart CI for wording: the CHANGELOG sentence "...no longer says "under" when the period is over." can be read as "after the period ends". Suggested: "...when the period would go over the limit."
Offline suite: 85/85 on 2929ece, with the same flags as before and the rig hwsim.
Round 1 findings:
F1 closed (N=1 is sized);
F2 closed (rows and lines are inside the limits, and the samples are graded);
F3 closed (the known-issues line is true now);
F4 closed (mutations 9 and 10 are now killed, and the edge cases are in part 8).
**SAFETY-125 round 2 review (Opus), head 2929ece.** Checks 1-8 were done offline. **Verdict: ready to merge once a real green `checks / unit` run lands on 2929ece.** Run 1430 ended as `failure` at 16:12 with no new test output after `test_version_gate.py`, 84/85 PASS: it was the stuck run cancelled after the runner was capped at 60 min, not a test failure. I did not rerun it.
1. **The N=1 change** (`main.py:3234-3247`) is right.
- It matches my `exp-n1.diff` on all 16 maintenance cases (8 at N=1, 8 at N=2): the same quarters to 0.1 W, the N=1 runs land on the aim (worst +13.6 W in the first quarter on a 10 s meter), and the N=2 runs are unchanged from round 1.
- Under a site stop at N=1 (`auto` off from 600 s to 900 s, cheap-window charge, peak banked): head and "the stop rule also at N=1" give the same command hash and the same claims. The stop holds 0 W, and the charge resumes at the room afterwards. Keeping the rule N>=2-only is right: there is no other pack to give.
2. **The N=1 behaviour changes are intended and harmless.** On the safety84 rig, N=1 now gives exactly N=2's numbers:
- `n1_charge`: guard engaged 125 -> 54 cycles, charging 355 -> 426 (= `n2_charge`);
- `n1_charge_banked`: 94 -> 47 (= `n2_charge_banked`);
- the guard still steps in when the house alone is over the aim.
- Byte-identical to base: `n1_charge_dear`, every `safety87` run except `n1_charge_low` (`n1_under`, `n1_crossing`, `n1_above`, `n1_under_banked`, `n1_hold`), and my N=1 runs: drain in a cheap window, drain outside one, hold in a cheap window.
- SAFETY-80/111 functions are not touched; SAFETY-84 at N>=2 is unchanged.
3. **The re-pins are honest.**
- `n1_charge` and `n1_charge_banked` now equal the N=2 runs of the same scenario.
- `n1_charge_low`: quarters 3 452.2 / 3 456.7 W (over the 3 450 W aim) -> 3 450.3 / 3 450.0 W; guard engaged 277 -> 126 cycles.
- The SAFETY-119 fixture still grades the shipped settle code on the hand-back shape it was built for. That shape now only comes from the old N=1 sizing, and the fixture names that. A new check shows that the shipped sizing gives no hand-back and no warning. Part 4 (N=2) uses the shipped code throughout.
4. **Mutations, all caught:**
- dev #11 (the N=1 early return put back): 7 fail;
- dev #13 (shadow ignored in the row): 1 fail;
- mine, N=1 sized with `charge_headroom_w()`, so late seconds are ignored at N=1: 4 fail.
5. **Digests vs base:** `digest.py` and `digest2.py` are identical (all 13 `digest2.py` runs). `digest3.py` is the same as round 1. The N=1 maintenance digests (above) change only where a quarter was over the aim, or where the guard's discharge at the quarter's end had pushed it under the aim. That discharge is the behaviour being removed.
6. **Text:** true to the code. One **Low** nit, not pushed so I don't restart CI for wording: the CHANGELOG sentence "...no longer says "under" when the period is over." can be read as "after the period ends". Suggested: "...when the period would go over the limit."
7. **Offline suite:** 85/85 on 2929ece, with the same flags as before and the rig hwsim.
8. **Round 1 findings:**
- F1 closed (N=1 is sized);
- F2 closed (rows and lines are inside the limits, and the samples are graded);
- F3 closed (the known-issues line is true now);
- F4 closed (mutations 9 and 10 are now killed, and the edge cases are in part 8).
admin
merged commit 8966867968 into hotfix-0.19.22026-10-29 18:21:16 +01:00
Merged into hotfix-0.19.2 at 8966867968, through the API with head_commit_id2929ece. Before the merge: CI run 1430, attempt 2 (job 2582), checks / unit success with 85 PASS on 2929ece, and run 1431 infra-pin success. After the merge, ls-remote shows hotfix-0.19.2 = 8966867, master = 2932f3a, release/1.0 = 4a1f6cc. No tag, no deploy. The CHANGELOG wording nit waits for the release.
Merged into `hotfix-0.19.2` at **896686796863bd066494a901d27f18f094f036fe**, through the API with `head_commit_id` 2929ece. Before the merge: CI run 1430, attempt 2 (job 2582), `checks / unit` success with 85 PASS on 2929ece, and run 1431 infra-pin success. After the merge, ls-remote shows `hotfix-0.19.2` = 8966867, `master` = 2932f3a, `release/1.0` = 4a1f6cc. No tag, no deploy. The CHANGELOG wording nit waits for the release.
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-125 (hotfix 0.19.2). Base
hotfix-0.19.2= master2932f3a(v0.19.1).What the live night showed, and what was wrong. The 00:15-00:30 quarter did not go above the month peak (it stayed 5 413 W). The guard cut the cheap run late in the quarter, as designed: the charge is sized to the quarter's room, and the room shrinks as the early high draw is counted. The live row was false, though: "Peak guard: idle, heading for 5 814 W, under this month's 5 413 W peak". The sim of the live shape (A charging about 11 % harder than asked, as measured live) reproduces both the trace (grid 4 924 W at 00:27:50, 3 860 W at 00:29:50) and the row, and lands the quarter at 5 367.6 W (aim 5 363 W).
The breach that is real. A meter reading late by 10-30 s near a quarter's end.
tariff.project()leaves the seconds since the last reading out until the next reading lands (SAFETY-99), socharge_headroom_w()reads them as room; near the end900 / leftturns that into thousands of W, and the cheap run goes back to -5 000 W for the last seconds. On 0.19.1, with a 30 s late reading at each quarter end: month peak banked 5 413 W -> the quarter lands at 5 419.5 W (over the month peak); no peak banked -> 3 519.7 W (over the 3 500 W cap); price trading with modes off -> 5 479.3 W / 3 564.7 W; maintenance at N=2 -> 5 380.8 W / 3 476.8 W. The bill counts those seconds; the add-on's bank did not (it banked 5 374 W).Fix.
peakguard.charge_room_w()=charge_headroom_w()lessheld_w x late seconds / seconds left. Used for the mode trade and price trading (main.py,arb_headroom_w) and the N>=2 maintenance charge (_charge_headroom()). The guard's own floor (decide()) is unchanged; SAFETY-99's discharge side stays as it is.config.yaml, CHANGELOG, DECISIONS note.Tests.
test_safety125.py(new). Mutations (tools/mutation_run.sh, purged copies) all caught: fix reverted 13 failed; trade path reverted 9; maintenance path reverted 2; row "over" branch removed 5; line "over" branch removed 3; mode-trade flag never set 4; row without the flag 2.Existing tests changed (needs your eye).
test_arbitrage.pypart 3b: the AST pin ofself.arb_headroom_w's expression follows the new call.test_price_run_fixes.pypart 5: the two lag pins (n1_lag,n2_lag) move. Their P1 sample is 0.35 s old on every cycle; those seconds now count, so 16 of 1 080 cycles charge about 1.7 W less (true quarter 2 147.6 -> 2 147.4 W, under the aim).n1andn2still equale3e4ba6.Digests. TARIFF-24's replay and gate digests (1-day opt11p3 replay, the 4 real-gate runs, the 13 3-day opt11p4 runs): identical to base, line for line. Lagged replay (OPT-15 Lag, 5 s cycle): modes off, Leftover solar and Cheap hours identical;
arb_onlychanges, its charge quarters over the aim go from [0.4, 0.9, 0.9, 1.2, 1.8, 21.5] W to [0.4, 20.1] W.Not touched: SAFETY-80, SAFETY-111 (
maintenance_charge_floor(),shaping_suspended(),peak_aim_w()), the saturation freeze, OPT-25's run sizing.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
SAFETY-125 review (Opus), head reviewed 88ab8a5; text fixes pushed as
6eebd48. Verdict: not merged. One finding needs a behaviour change (F1), so it goes back to the manager and the owner.What holds
2932f3a. I used my own harness: the real Controller, the live shape, a 1 s cycle with OPT-15's lag, and the true grid integrated per sub-step. With the last 30 s of each quarter silent, the quarter lands at 5 419.6 W (above the 5 413 W peak) and 3 519.7 W with no peak banked. With modes off and price trading, it lands at 5 479.4 W. The cause istariff.pyproject()together withpeakguard.charge_headroom_w(): the seconds since the last reading count as room, and900/leftmakes them large. The forecast entity changes nothing, in kW or in W.charge_room_w()equalscharge_headroom_w()of the projection that counts the late seconds at the held reading (20 000 random cases, 0 differ). It stays finite near 0 s left, passes through None at 0 s left and past the end, and gives 0 W when the guard is blind.fill_gaps()runs first, so gap and unseen seconds are not counted twice.decide(),charge_headroom_w(), the discharge side and the saturation freeze are untouched.digest.pyanddigest2.pyare identical to base and to the dev's (their meters have no late seconds). Indigest3.pyonly arb_only changes, as the dev reported.late_ws / QUARTER_Sis caught (13 fail).goodwe-addon-checks:1(--network none, hwsim): 85/85 on88ab8a5and 85/85 on6eebd48. CI runs 1426 and 1427 on88ab8a5are green (85 PASS,test_freezeon the rig hwsim).config.yaml, the same single place the 0.19.1 hotfix changed.Findings
F1 (High for the product; not a regression; blocks the merge): the maintenance charge at N=1 does not use
charge_room_w().app/main.py:3239:_charge_headroom()returnsfloorat N<2. So the N=1 cheap-window charge still runs atcharge_wuntil the guard engages. A late reading releases it again (SAFETY-99). I measured it on the safety84 rig (5 s cycle). Base and head give the same numbers:6eebd48is merged.F2 (Medium, fixed in
6eebd48): the new strings broke UX-08, and the gates did not see them. Inapp/messages.py:1173, the rows ran over 15 words: EN 16/19 and NL 16/21. Two Dutch Home sentences ran over 12 words (ui_text/nl.json:355-356). None of them was instatus_samples()/text_samples(). Old and new text:peak_what)The under row is unchanged. All of them are now in the samples.
test_safety125.pyfollows.F3 (Low, fixed in
6eebd48): the docs did not match the code. The CHANGELOG known-issues line said SAFETY-99 "now covers the guard's discharge only, not a grid charge". That is false at N=1 (F1). It now reads: "...covers the guard's discharge, and the maintenance charge with one inverter." The DECISIONS note says the same. The DOCS status table explains the new row words.F4 (Low): test gaps.
test_safety125.py:self.room_charge = ...in_charge_headroom()(main.py:3246): no word check during an N=2 maintenance charge;cut=self.room_chargewithoutand not self.shadow(main.py:7083): no shadow-mode row check.scratch/SAFETY-125/review/).F5 (Info): residuals and the quarters that change while under the aim. All are explained, none goes above the peak or the cap.
n1_lag/n2_lagpins: confirmed. 16 of 1 080 cycles charge 1.7-2.0 W less, and the quarter goes from 2 147.56 to 2 147.41 W.ch_ch: 3 451.3-3 451.4 W -> 3 450.0 W.Details:
reports/SAFETY-125-review-report.md.CI on
6eebd48(the review text fixes): run 1428checks / unitsuccess, 85 PASS (test_freeze on the rig hwsim, infra 3b4ff84); run 1429infra-pin / freshsuccess. Still not merged: F1 (the N=1 maintenance charge) needs an owner/manager decision. If F1 is accepted as known (SAFETY-99),6eebd48is ready to merge.Round 2 (owner 2026-10-29: fix N=1 in this hotfix), head
2929eceon6eebd48._charge_headroom()(main.py:3234-3247): the N=1 maintenance charge is now sized tocharge_room_w()as at N=2. SAFETY-84's site-stop rule stays N>=2 only: at N=1 a stop outranks the shaping anyway, and there is no other pack to give. N=1 effects: the quarter lands on the aim, the guard no longer steps in or gives power at the quarter's end during the charge (engagements 24 -> 0 in the sweep), the battery charges every cycle at a lower rate, and the row can say "grid charge limited". Unchanged: no P1, outside a cheap window, HOLD/DRAIN, SAFETY-80/111 functions, N>=2.test_safety125.pyparts 6-8: N=1 on 5 s and 10 s meters with late readings, banked and not; the N=2 maintenance row and the shadow row (kill review mutations 9 and 10); edge cases. The N=1 tests fail on6eebd48(7). Mutations: N=1 reverted 7 failed; review 9 and 10, 1 failed each; round 1's fix reverted 22 failed.test_safety84n1_charge,n1_charge_banked;test_safety87n1_charge_low.test_safety119's quarter-edge runs keep the old N=1 sizing as a named fixture (the hand-back they grade no longer happens), plus a check that with the new sizing nothing hands back.digest.py,digest2.pyidentical;digest3.pyas round 1. N=1 maintenance digests and the sweep table: in the dev report, Round 2.2929ece.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
SAFETY-125 round 2 review (
2929ece): stopped, not merged. CIchecks / unitrun 1430 (job 2580) was cut off by the runner, not by a test:timeout: 60m(/home/homelab/docker/goodwe-runner/config.yaml);test_version_gate.pyat 14:57:10Z;test_watchdog.py(68 s in earlier runs) has no result;in_progress, and the commit status ispending.Round 1 ran 56-57 min. Round 2 adds about 4 min of tests, so every run of this branch now goes over the runner limit. No rerun, per the brief. The runner limit, or the suite time, needs a manager decision before this PR can get a real green run. The substantive round 2 review was not started past reading the diff and the dev report.
SAFETY-125 round 2 review (Opus), head
2929ece. Checks 1-8 were done offline. Verdict: ready to merge once a real greenchecks / unitrun lands on2929ece. Run 1430 ended asfailureat 16:12 with no new test output aftertest_version_gate.py, 84/85 PASS: it was the stuck run cancelled after the runner was capped at 60 min, not a test failure. I did not rerun it.main.py:3234-3247) is right.exp-n1.diffon all 16 maintenance cases (8 at N=1, 8 at N=2): the same quarters to 0.1 W, the N=1 runs land on the aim (worst +13.6 W in the first quarter on a 10 s meter), and the N=2 runs are unchanged from round 1.autooff from 600 s to 900 s, cheap-window charge, peak banked): head and "the stop rule also at N=1" give the same command hash and the same claims. The stop holds 0 W, and the charge resumes at the room afterwards. Keeping the rule N>=2-only is right: there is no other pack to give.n1_charge: guard engaged 125 -> 54 cycles, charging 355 -> 426 (=n2_charge);n1_charge_banked: 94 -> 47 (=n2_charge_banked);n1_charge_dear, everysafety87run exceptn1_charge_low(n1_under,n1_crossing,n1_above,n1_under_banked,n1_hold), and my N=1 runs: drain in a cheap window, drain outside one, hold in a cheap window.n1_chargeandn1_charge_bankednow equal the N=2 runs of the same scenario.n1_charge_low: quarters 3 452.2 / 3 456.7 W (over the 3 450 W aim) -> 3 450.3 / 3 450.0 W; guard engaged 277 -> 126 cycles.charge_headroom_w(), so late seconds are ignored at N=1: 4 fail.digest.pyanddigest2.pyare identical (all 13digest2.pyruns).digest3.pyis the same as round 1. The N=1 maintenance digests (above) change only where a quarter was over the aim, or where the guard's discharge at the quarter's end had pushed it under the aim. That discharge is the behaviour being removed.2929ece, with the same flags as before and the rig hwsim.Merged into
hotfix-0.19.2at8966867968, through the API withhead_commit_id2929ece. Before the merge: CI run 1430, attempt 2 (job 2582),checks / unitsuccess with 85 PASS on2929ece, and run 1431 infra-pin success. After the merge, ls-remote showshotfix-0.19.2=8966867,master=2932f3a,release/1.0=4a1f6cc. No tag, no deploy. The CHANGELOG wording nit waits for the release.