SAFETY-135: one peak-guard stop no longer stops a night grid charge for the whole 15-minute period.
What changes (battery modes only)
The first stop of a grid charge in a period by the peak guard (or by a measured headroom in the period's first 60 s) no longer latches. A measured headroom stop later in the period latches as before. The charge may start again 60 s after that stop (RESTART_S), under the normal start rule (guard clear, headroom over START_W), but not in the period's last 60 s. A second stop latches to the period's end, so there is at most one restart per period. An unmeasured headroom (OPT-15) still latches on its second stop.
In a period's first 60 s (YOUNG_S) no charge starts while the batteries give over START_W less than they did in the last 15 s (GAVE_S): the meter may not show that drop yet.
While a start waits, the status reads "battery modes: grid charge paused, it tries again in a minute" or "... grid charge waits for the meter to catch up" (UX-08), not "no trade (young)".
The peak guard is not touched (claim, floor, projection): a real over-aim load is cut on the same cycle as before. Modes off (arbitrage.decide()) is not touched. No option.
Round 2 (review M-1, L-1, L-2, I-2): rule 1 now keys on the first guard stop in a period, whatever its age. Round 1 freed only a stop in the period's first 60 s, so it missed the x1 rig case (SAFETY-136 rig run C/s5b): charge start 01:00:32 in an oven pause, guard cut 01:01:31 when the oven came back, charge off to 01:15. test_safety135.py part 4 replays that run's house through cycle(). Head: same start and same cut as base (not delayed), one restart at 01:02:32, the oven cuts it again at 01:03:17 and that second stop latches to the period's end; period 2 297 W (base 2 223 W), under the aim. Part 4 fails on base and on 66d8777. Part 5 pins "at most one restart, none sooner than 60 s"; the restart-at-once mutant fails it. Hardening: _give_falling() starts its window with getattr/setattr, not __dict__. Sensitivity numbers for the three constants are in DECISIONS/SAFETY-135-young-period.md.
Sim (real cycle(), TEST-23-like lag, aim 3 450 W)
Scenario
Base: paused s / kWh / top period
Head
(a) 4 s spike at period start, house 2.8 kW
1 197 / 0.330 / 3 458 W
139 / 0.537 / 3 458 W
(b) 01:00 edge after a give, 5 s meter, 3.0 kW
882 / 0.006 / 3 009 W, 1 flap
21 / 0.119 / 3 464 W, 0 flaps
Rig s5b (oven), 1 s meter
838 / 0.054 / 2 224 W
793 / 0.075 / 2 299 W
(c) +4 kW for 5 min
cut 01:30:22, 0.387 kWh
cut 01:30:22, 0.492 kWh
+4 kW from second 30/57/59/60/61, N=1 and N=2
cut at 32/59/61/62/63 s
same cycle; restart after the load; period 3 450.0 W
Owner-shape night N=1 / N=2
5.510 / 6.048 kWh, top 3 454 / 3 453 W
5.510 / 6.169 kWh, top 3 454 / 3 453 W
Tests:test_safety135.py parts 1-5. Mutations (fresh copies, -B): base fails parts 1, 2 and 4 and the wording check of 5; 66d8777 fails parts 4 and 5; "restart at once" fails parts 4 and 5; "a running charge ignores guard and sizing in a period's first 60 s" (delayed cut) fails parts 1, 2 and 3; "no hold while a give falls" fails part 2.
Full local suite on 4066624's tree (CI script, pinned sim): 104 files, 0 failed. Three existing checks that pinned "one start per period" move with rule 1: test_modes_p3.py, test_price_run_fixes.py 1c, and the n1_ch digest in test_modes_p4.py (only the waiting reason word moved; with this card off it is OPT-15's pin again).
CHANGELOG
With battery modes on, when the peak guard stops a night grid charge, the charge may now start again once in the same 15-minute period, one minute after the stop and not in the period's last minute, under the normal start rule. A second stop keeps it off to the period's end. Before, one stop (a 2-second spike, or an oven switching back on) kept the charge off for the rest of the period. At the price change, a cheap-hours charge no longer starts while a battery is still stopping its give, which made the peak guard stop it seconds later (SAFETY-135).
SAFETY-135: one peak-guard stop no longer stops a night grid charge for the whole 15-minute period.
**What changes (battery modes only)**
- The first stop of a grid charge in a period by the peak guard (or by a measured headroom in the period's first 60 s) no longer latches. A measured headroom stop later in the period latches as before. The charge may start again 60 s after that stop (`RESTART_S`), under the normal start rule (guard clear, headroom over `START_W`), but not in the period's last 60 s. A second stop latches to the period's end, so there is at most one restart per period. An unmeasured headroom (OPT-15) still latches on its second stop.
- In a period's first 60 s (`YOUNG_S`) no charge starts while the batteries give over `START_W` less than they did in the last 15 s (`GAVE_S`): the meter may not show that drop yet.
- While a start waits, the status reads "battery modes: grid charge paused, it tries again in a minute" or "... grid charge waits for the meter to catch up" (UX-08), not "no trade (young)".
- The peak guard is not touched (claim, floor, projection): a real over-aim load is cut on the same cycle as before. Modes off (`arbitrage.decide()`) is not touched. No option.
**Round 2 (review M-1, L-1, L-2, I-2):** rule 1 now keys on the first guard stop in a period, whatever its age. Round 1 freed only a stop in the period's first 60 s, so it missed the x1 rig case (SAFETY-136 rig run C/s5b): charge start 01:00:32 in an oven pause, guard cut 01:01:31 when the oven came back, charge off to 01:15. `test_safety135.py` part 4 replays that run's house through `cycle()`. Head: same start and same cut as base (not delayed), one restart at 01:02:32, the oven cuts it again at 01:03:17 and that second stop latches to the period's end; period 2 297 W (base 2 223 W), under the aim. Part 4 fails on base and on 66d8777. Part 5 pins "at most one restart, none sooner than 60 s"; the restart-at-once mutant fails it. Hardening: `_give_falling()` starts its window with getattr/setattr, not `__dict__`. Sensitivity numbers for the three constants are in DECISIONS/SAFETY-135-young-period.md.
**Sim (real `cycle()`, TEST-23-like lag, aim 3 450 W)**
| Scenario | Base: paused s / kWh / top period | Head |
|---|---|---|
| (a) 4 s spike at period start, house 2.8 kW | 1 197 / 0.330 / 3 458 W | 139 / 0.537 / 3 458 W |
| (b) 01:00 edge after a give, 5 s meter, 3.0 kW | 882 / 0.006 / 3 009 W, 1 flap | 21 / 0.119 / 3 464 W, 0 flaps |
| Rig s5b (oven), 1 s meter | 838 / 0.054 / 2 224 W | 793 / 0.075 / 2 299 W |
| (c) +4 kW for 5 min | cut 01:30:22, 0.387 kWh | cut 01:30:22, 0.492 kWh |
| +4 kW from second 30/57/59/60/61, N=1 and N=2 | cut at 32/59/61/62/63 s | same cycle; restart after the load; period 3 450.0 W |
| Owner-shape night N=1 / N=2 | 5.510 / 6.048 kWh, top 3 454 / 3 453 W | 5.510 / 6.169 kWh, top 3 454 / 3 453 W |
**Tests:** `test_safety135.py` parts 1-5. Mutations (fresh copies, `-B`): base fails parts 1, 2 and 4 and the wording check of 5; 66d8777 fails parts 4 and 5; "restart at once" fails parts 4 and 5; "a running charge ignores guard and sizing in a period's first 60 s" (delayed cut) fails parts 1, 2 and 3; "no hold while a give falls" fails part 2.
Full local suite on 4066624's tree (CI script, pinned sim): 104 files, 0 failed. Three existing checks that pinned "one start per period" move with rule 1: `test_modes_p3.py`, `test_price_run_fixes.py` 1c, and the `n1_ch` digest in `test_modes_p4.py` (only the waiting reason word moved; with this card off it is OPT-15's pin again).
**CHANGELOG**
- With battery modes on, when the peak guard stops a night grid charge, the charge may now start again once in the same 15-minute period, one minute after the stop and not in the period's last minute, under the normal start rule. A second stop keeps it off to the period's end. Before, one stop (a 2-second spike, or an oven switching back on) kept the charge off for the rest of the period. At the price change, a cheap-hours charge no longer starts while a battery is still stopping its give, which made the peak guard stop it seconds later ([[SAFETY-135]]).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
With battery modes on, a grid charge the peak guard (or a measured headroom)
stops in the first 60 s of a period no longer latches off for the period: it
may start again once the period is 60 s old, if the guard is clear and the
headroom is over START_W. A stop after 60 s latches as before. In a young
period no charge starts while the batteries' give is still falling (over
START_W within 15 s), since the meter may not show that drop yet. The guard
itself is unchanged, so a real over-aim load is cut on the same cycle.
DECISIONS/SAFETY-135-young-period.md, test_safety135.py.
Co-Authored-By: Claude <noreply@anthropic.com>
SAFETY-135 review (Opus, independent; no model-router verdict for an Opus dev) — head 66d8777, base a8ecf17
Verdict: NOT READY (one Medium). The code is safe: no path found where it delays or weakens a peak cut or lets a period go over the aim. The open point is that the x1 rig case this PR says it fixes is not fixed.
Medium
M-1 The x1 rig edge case is not covered, but the docs say it is.DECISIONS/SAFETY-135-young-period.md:15-19 and the dev report ("same shape as the SAFETY-136 rig base") cite the SAFETY-136 rig run (scratch/SAFETY-136/test/runs/C/s5b). In that run the give fell from +2 629 W to +323 W at 01:00:02-07. The charge started at 01:00:32, 30 s into the period (1 825 W, with the meter reading 1 451 W). The guard cut it at 01:01:31, 91 s into the period ("heads for 5273 W"), and the latch held for the rest of the period. Head does the same as base there. Rule 2's 15 s window at 01:00:32 holds battery readings of 193-266 W (a drop under 100 W, not over 500 W), and rule 1 (app/ctl_trade.py:406,427) only covers stops before 60 s. Sim case (b) (start at 4 s, cut at 20 s) is a different, synthetic shape. Round 2, owner's choice: (a) cover the rig shape, for example key rule 1 to the first measured stop in a period, not a stop before 60 s, with the restart no sooner than 60 s after it (still at most one restart). Add a test built from the rig trace. Or (b) correct DECISIONS:9-19, the PR text and the CHANGELOG line to say the 30 s start / 91 s cut case stays, and file that case as its own card.
Low
L-1 The "at most one restart, not before 60 s" rule is not pinned. Mutant young=falling (app/ctl_trade.py:416, which drops _charge_young_q, so a young stop may restart at once and flip again within the 60 s) passes test_safety135.py. Head itself behaves correctly. With 4 s spikes at 0/20/40/62/80 s (N=1 and N=2) it stops at 2 s, restarts at 60 s, stops at 64 s and stays latched. Add a check like that.
L-2 YOUNG_S 60 s and GAVE_S 15 s are reasoned, not measured (app/modetrade.py:75-79, DECISIONS:22-33). Add a short sensitivity line (for example 30/60/90 s and 10/15/30 s against sims a/b). The rig cut at 91 s shows that the 60 s edge matters. START_W 500 W is fine: a give drop of 500 W or less left for at most 15 s moves the period average by 8 W or less.
Info
I-1 On the dev's own synthetic night N=2 (night2, not in the dev's table), head is 1-2 W over base in 5 periods (for example 3 783 vs 3 782 W). These periods are already about 300 W over the aim from the house alone, base too. Rule 2 held the 01:00 start for 25 s, which shifts the later run. Head also charges +0.001-0.002 kWh. This is not a safety issue.
I-2 For up to 60 s the status row reads "battery modes: no trade (young)". That is the same style as (peak) and (headroom), but "young" is an internal word (UX-08).
I-3 Leaving out the carry-over is correct. It wants its own card, and the board has none (SAFETY-124 is the give side, SAFETY-88 is the N=2 maintenance charge, OPT-25 is run planning). Proposed title: "A grid charge at full power in a period's last seconds runs into the next period and trips the peak guard there".
I-4 DECISIONS "Status: for review" should be updated when this merges.
What reproduced: all 36 sim runs (base and head: spike, sustain, edge on 1 s and 5 s meters at 2.4/3.0/3.6 kW, owner night and day at N=1 and N=2, night1/2) match the dev's table exactly. Extra cases: a +4 kW load from second 30, 57, 59, 60 or 61 (N=1 and N=2) is cut on the same cycle as base, the charge stays off while the load runs, and the period lands at 3 450.0 W or under. Mutants on test_safety135.py: base fails 5 checks, delayed cut (decide plus sizing) fails 7, young stop latches fails 1, no hold while the give falls fails 2, young stop on an OPT-15 gap is caught by test_price_run_fixes.py, young=fallingsurvives (L-1). CI run 1596 checks / unit passed on 66d8777. Full local suite on 66d8777 (pinned admin/goodwe 3b4ff84, GOODWE_REQUIRE_HWSIM=1, --network none): 104 files, 0 failed, 2 123 s; test_safety135, test_split_patches (MAINT-21 tripwire), test_version_gate, test_peakguard, test_safety90, test_price_run_fixes all pass.
**SAFETY-135 review (Opus, independent; no model-router verdict for an Opus dev) — head 66d8777, base a8ecf17**
**Verdict: NOT READY** (one Medium). The code is safe: no path found where it delays or weakens a peak cut or lets a period go over the aim. The open point is that the x1 rig case this PR says it fixes is not fixed.
**Medium**
- **M-1 The x1 rig edge case is not covered, but the docs say it is.** `DECISIONS/SAFETY-135-young-period.md:15-19` and the dev report ("same shape as the SAFETY-136 rig base") cite the SAFETY-136 rig run (`scratch/SAFETY-136/test/runs/C/s5b`). In that run the give fell from +2 629 W to +323 W at 01:00:02-07. The charge started at 01:00:32, 30 s into the period (1 825 W, with the meter reading 1 451 W). The guard cut it at 01:01:31, **91 s** into the period ("heads for 5273 W"), and the latch held for the rest of the period. Head does the same as base there. Rule 2's 15 s window at 01:00:32 holds battery readings of 193-266 W (a drop under 100 W, not over 500 W), and rule 1 (`app/ctl_trade.py:406,427`) only covers stops before 60 s. Sim case (b) (start at 4 s, cut at 20 s) is a different, synthetic shape. Round 2, owner's choice: (a) cover the rig shape, for example key rule 1 to the first measured stop in a period, not a stop before 60 s, with the restart no sooner than 60 s after it (still at most one restart). Add a test built from the rig trace. Or (b) correct DECISIONS:9-19, the PR text and the CHANGELOG line to say the 30 s start / 91 s cut case stays, and file that case as its own card.
**Low**
- **L-1 The "at most one restart, not before 60 s" rule is not pinned.** Mutant `young=falling` (`app/ctl_trade.py:416`, which drops `_charge_young_q`, so a young stop may restart at once and flip again within the 60 s) passes `test_safety135.py`. Head itself behaves correctly. With 4 s spikes at 0/20/40/62/80 s (N=1 and N=2) it stops at 2 s, restarts at 60 s, stops at 64 s and stays latched. Add a check like that.
- **L-2 `YOUNG_S` 60 s and `GAVE_S` 15 s are reasoned, not measured** (`app/modetrade.py:75-79`, DECISIONS:22-33). Add a short sensitivity line (for example 30/60/90 s and 10/15/30 s against sims a/b). The rig cut at 91 s shows that the 60 s edge matters. `START_W` 500 W is fine: a give drop of 500 W or less left for at most 15 s moves the period average by 8 W or less.
**Info**
- I-1 On the dev's own synthetic night N=2 (`night2`, not in the dev's table), head is 1-2 W over base in 5 periods (for example 3 783 vs 3 782 W). These periods are already about 300 W over the aim from the house alone, base too. Rule 2 held the 01:00 start for 25 s, which shifts the later run. Head also charges +0.001-0.002 kWh. This is not a safety issue.
- I-2 For up to 60 s the status row reads "battery modes: no trade (young)". That is the same style as (peak) and (headroom), but "young" is an internal word (UX-08).
- I-3 Leaving out the carry-over is correct. It wants its own card, and the board has none (SAFETY-124 is the give side, SAFETY-88 is the N=2 maintenance charge, OPT-25 is run planning). Proposed title: "A grid charge at full power in a period's last seconds runs into the next period and trips the peak guard there".
- I-4 DECISIONS "Status: for review" should be updated when this merges.
**What reproduced:** all 36 sim runs (base and head: spike, sustain, edge on 1 s and 5 s meters at 2.4/3.0/3.6 kW, owner night and day at N=1 and N=2, night1/2) match the dev's table exactly. Extra cases: a +4 kW load from second 30, 57, 59, 60 or 61 (N=1 and N=2) is cut on the same cycle as base, the charge stays off while the load runs, and the period lands at 3 450.0 W or under. Mutants on `test_safety135.py`: base fails 5 checks, delayed cut (decide plus sizing) fails 7, young stop latches fails 1, no hold while the give falls fails 2, young stop on an OPT-15 gap is caught by `test_price_run_fixes.py`, `young=falling` **survives** (L-1). CI run 1596 `checks / unit` passed on 66d8777. Full local suite on 66d8777 (pinned admin/goodwe 3b4ff84, `GOODWE_REQUIRE_HWSIM=1`, `--network none`): 104 files, 0 failed, 2 123 s; `test_safety135`, `test_split_patches` (MAINT-21 tripwire), `test_version_gate`, `test_peakguard`, `test_safety90`, `test_price_run_fixes` all pass.
SAFETY-135 re-review, round 2 (Opus): head 4066624, base a8ecf17
Verdict: MERGE-READY as is (4066624). Nothing is above Low. Round 1's M-1, L-1, L-2 and I-2 are fixed.
Round 1 findings
M-1 fixed. Rule 1 now applies to the first guard stop in a period, whatever its age (app/ctl_trade.py:410-440). Test part 4 replays the rig's s5b house (identical to SAFETY-136/test/ctl/s5b.json) through cycle():
off: start 32 s, stop 92 s, period 2 222.87 W, the same as base a8ecf17's own run;
the cut is not delayed.
My sim, s5b 1 s / 5 s meter: base 0.054 / 0.035 kWh, 2 224 / 2 123 W; head 0.075 / 0.084 kWh, 2 299 / 2 286 W.
L-1 fixed. Part 5 pins "one restart, not before 60 s". My restart-at-once mutant fails parts 4 and 5 (restart at 9 s, stop at 22 s).
L-2 fixed. DECISIONS has a sensitivity table.
I-2 fixed.WAIT_WORDS replaces the internal word "(young)" in the status row with plain words, which part 5 checks.
Docs claims checked
The +4 kW load from second 30/57/59/60/61 (N=1 and N=2) reproduces exactly:
it is cut on the same cycle as base (32/59/61/62/63 s);
the charge restarts only after the load ends (N=2 at 335-366 s, N=1 at 335-719 s);
every period lands at 3 450.0 W.
Repeated spikes at 0/20/40/62/80 s: stop at 2 s, one restart at 62 s, stop at 64 s, then latched. Period 2 820 W.
Owner night N=2: 6.169 kWh against base 6.048, top 3 453 W, the same as base. Spike, edge, sustain and owner-day runs match the PR table exactly.
The n1_ch re-pin claim holds. I diffed all 1 080 rows of base against head: only the reason text differs (118 cycles), and no command moved.
The _off() baselines (RESTART_S/YOUNG_S set to 0, _charge_stop = (_Every(), -inf)) reproduce the pre-card tree:
by reading the code: every guard stop latches, nothing waits, and _give_falling returns False;
by numbers: in part 4, off and base a8ecf17 give the same 2 222.87 W; in part 1, the same 2 807.9 W;
test_modes_p4's "off = OPT-15 pin" check passes.
_give_falling now uses getattr with a clock-step-back clear. That is fine.
test_modes_p3_one_restart correctly pins at most one restart, at least RESTART_S after the stop, and no start after a second stop. test_price_run_fixes 1c keeps its intent: a real guard stop still holds, now for at least RESTART_S, and then the latch.
Low
L-3 Nothing tests the "no restart in the period's last RESTART_S" rule (app/ctl_trade.py:413-414). If that clause is removed, test_safety135, test_price_run_fixes, test_modes_p3 and test_modes_p4 all still pass. Yet the owner night N=2 then lands at 3 465 W: over the 3 450 W aim and 12 W over base. Head's own behaviour is right (3 453 W). This wants a small test, for example a first stop at about 850 s with no restart before the period ends. A follow-up card or the next touch of this code is enough.
L-4 The disclosed +2 W on night1 (N=1, house alone 950 W over the aim): head's top period is 4 403 W against base's 4 401 W. I checked the mechanism. At 01:29:59 the heat pump starts while head's restarted charge is still drawing about 530 W, and that charge fades over about 10 s into the new period. That is the known carry-over, which base also has whenever a charge runs at a period end. Here the aim is the month peak (SAFETY-125), so head charges +0.42 kWh up to it. This is not new behaviour and not an owner trade-off, but it supports filing the carry-over card (round 1 I-3).
Info
s5b: the restart at 152 s is cut again by the oven at 197 s, as designed (one restart). Under the sim's "stop within 60 s" metric that counts as a second flap.
DECISIONS still says "Status: for review, round 2". Update it with the merge or the release.
CI and suite
CI run 909 (id 1598) checks / unit (pull_request) success on 4066624, and infra-pin / fresh success.
My full local suite on 4066624 (pinned admin/goodwe 3b4ff84, GOODWE_REQUIRE_HWSIM=1, --network none) passed: 104 files, 0 failed, 2 019 s, with a host load of about 20-26.
**SAFETY-135 re-review, round 2 (Opus): head 4066624, base a8ecf17**
**Verdict: MERGE-READY as is (4066624).** Nothing is above Low. Round 1's M-1, L-1, L-2 and I-2 are fixed.
**Round 1 findings**
- **M-1 fixed.** Rule 1 now applies to the first *guard* stop in a period, whatever its age (`app/ctl_trade.py:410-440`). Test part 4 replays the rig's s5b house (identical to `SAFETY-136/test/ctl/s5b.json`) through `cycle()`:
- head: starts 32/152 s, stops 92/197 s, period 2 297 W;
- off: start 32 s, stop 92 s, period 2 222.87 W, the same as base a8ecf17's own run;
- the cut is not delayed.
- My sim, s5b 1 s / 5 s meter: base 0.054 / 0.035 kWh, 2 224 / 2 123 W; head 0.075 / 0.084 kWh, 2 299 / 2 286 W.
- **L-1 fixed.** Part 5 pins "one restart, not before 60 s". My restart-at-once mutant fails parts 4 and 5 (restart at 9 s, stop at 22 s).
- **L-2 fixed.** DECISIONS has a sensitivity table.
- **I-2 fixed.** `WAIT_WORDS` replaces the internal word "(young)" in the status row with plain words, which part 5 checks.
**Docs claims checked**
- The +4 kW load from second 30/57/59/60/61 (N=1 and N=2) reproduces exactly:
- it is cut on the same cycle as base (32/59/61/62/63 s);
- the charge restarts only after the load ends (N=2 at 335-366 s, N=1 at 335-719 s);
- every period lands at 3 450.0 W.
- Repeated spikes at 0/20/40/62/80 s: stop at 2 s, one restart at 62 s, stop at 64 s, then latched. Period 2 820 W.
- Owner night N=2: 6.169 kWh against base 6.048, top 3 453 W, the same as base. Spike, edge, sustain and owner-day runs match the PR table exactly.
- The `n1_ch` re-pin claim holds. I diffed all 1 080 rows of base against head: only the reason text differs (118 cycles), and no command moved.
- The `_off()` baselines (`RESTART_S`/`YOUNG_S` set to 0, `_charge_stop = (_Every(), -inf)`) reproduce the pre-card tree:
- by reading the code: every guard stop latches, nothing waits, and `_give_falling` returns False;
- by numbers: in part 4, off and base a8ecf17 give the same 2 222.87 W; in part 1, the same 2 807.9 W;
- `test_modes_p4`'s "off = OPT-15 pin" check passes.
- `_give_falling` now uses getattr with a clock-step-back clear. That is fine.
- `test_modes_p3` `_one_restart` correctly pins at most one restart, at least `RESTART_S` after the stop, and no start after a second stop. `test_price_run_fixes` 1c keeps its intent: a real guard stop still holds, now for at least `RESTART_S`, and then the latch.
**Low**
- **L-3 Nothing tests the "no restart in the period's last `RESTART_S`" rule** (`app/ctl_trade.py:413-414`). If that clause is removed, `test_safety135`, `test_price_run_fixes`, `test_modes_p3` and `test_modes_p4` all still pass. Yet the owner night N=2 then lands at 3 465 W: over the 3 450 W aim and 12 W over base. Head's own behaviour is right (3 453 W). This wants a small test, for example a first stop at about 850 s with no restart before the period ends. A follow-up card or the next touch of this code is enough.
- **L-4 The disclosed +2 W on `night1`** (N=1, house alone 950 W over the aim): head's top period is 4 403 W against base's 4 401 W. I checked the mechanism. At 01:29:59 the heat pump starts while head's restarted charge is still drawing about 530 W, and that charge fades over about 10 s into the new period. That is the known carry-over, which base also has whenever a charge runs at a period end. Here the aim is the month peak (SAFETY-125), so head charges +0.42 kWh up to it. This is not new behaviour and not an owner trade-off, but it supports filing the carry-over card (round 1 I-3).
**Info**
- s5b: the restart at 152 s is cut again by the oven at 197 s, as designed (one restart). Under the sim's "stop within 60 s" metric that counts as a second flap.
- DECISIONS still says "Status: for review, round 2". Update it with the merge or the release.
**CI and suite**
- CI run 909 (id 1598) `checks / unit (pull_request)` success on 4066624, and `infra-pin / fresh` success.
- My full local suite on 4066624 (pinned admin/goodwe 3b4ff84, `GOODWE_REQUIRE_HWSIM=1`, `--network none`) passed: **104 files, 0 failed, 2 019 s**, with a host load of about 20-26.
admin
merged commit 02eac3b84e into release/1.02026-11-11 20:55:31 +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-135: one peak-guard stop no longer stops a night grid charge for the whole 15-minute period.
What changes (battery modes only)
RESTART_S), under the normal start rule (guard clear, headroom overSTART_W), but not in the period's last 60 s. A second stop latches to the period's end, so there is at most one restart per period. An unmeasured headroom (OPT-15) still latches on its second stop.YOUNG_S) no charge starts while the batteries give overSTART_Wless than they did in the last 15 s (GAVE_S): the meter may not show that drop yet.arbitrage.decide()) is not touched. No option.Round 2 (review M-1, L-1, L-2, I-2): rule 1 now keys on the first guard stop in a period, whatever its age. Round 1 freed only a stop in the period's first 60 s, so it missed the x1 rig case (SAFETY-136 rig run C/s5b): charge start 01:00:32 in an oven pause, guard cut 01:01:31 when the oven came back, charge off to 01:15.
test_safety135.pypart 4 replays that run's house throughcycle(). Head: same start and same cut as base (not delayed), one restart at 01:02:32, the oven cuts it again at 01:03:17 and that second stop latches to the period's end; period 2 297 W (base 2 223 W), under the aim. Part 4 fails on base and on66d8777. Part 5 pins "at most one restart, none sooner than 60 s"; the restart-at-once mutant fails it. Hardening:_give_falling()starts its window with getattr/setattr, not__dict__. Sensitivity numbers for the three constants are in DECISIONS/SAFETY-135-young-period.md.Sim (real
cycle(), TEST-23-like lag, aim 3 450 W)Tests:
test_safety135.pyparts 1-5. Mutations (fresh copies,-B): base fails parts 1, 2 and 4 and the wording check of 5;66d8777fails parts 4 and 5; "restart at once" fails parts 4 and 5; "a running charge ignores guard and sizing in a period's first 60 s" (delayed cut) fails parts 1, 2 and 3; "no hold while a give falls" fails part 2.Full local suite on 4066624's tree (CI script, pinned sim): 104 files, 0 failed. Three existing checks that pinned "one start per period" move with rule 1:
test_modes_p3.py,test_price_run_fixes.py1c, and then1_chdigest intest_modes_p4.py(only the waiting reason word moved; with this card off it is OPT-15's pin again).CHANGELOG
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
SAFETY-135 review (Opus, independent; no model-router verdict for an Opus dev) — head
66d8777, basea8ecf17Verdict: NOT READY (one Medium). The code is safe: no path found where it delays or weakens a peak cut or lets a period go over the aim. The open point is that the x1 rig case this PR says it fixes is not fixed.
Medium
DECISIONS/SAFETY-135-young-period.md:15-19and the dev report ("same shape as the SAFETY-136 rig base") cite the SAFETY-136 rig run (scratch/SAFETY-136/test/runs/C/s5b). In that run the give fell from +2 629 W to +323 W at 01:00:02-07. The charge started at 01:00:32, 30 s into the period (1 825 W, with the meter reading 1 451 W). The guard cut it at 01:01:31, 91 s into the period ("heads for 5273 W"), and the latch held for the rest of the period. Head does the same as base there. Rule 2's 15 s window at 01:00:32 holds battery readings of 193-266 W (a drop under 100 W, not over 500 W), and rule 1 (app/ctl_trade.py:406,427) only covers stops before 60 s. Sim case (b) (start at 4 s, cut at 20 s) is a different, synthetic shape. Round 2, owner's choice: (a) cover the rig shape, for example key rule 1 to the first measured stop in a period, not a stop before 60 s, with the restart no sooner than 60 s after it (still at most one restart). Add a test built from the rig trace. Or (b) correct DECISIONS:9-19, the PR text and the CHANGELOG line to say the 30 s start / 91 s cut case stays, and file that case as its own card.Low
young=falling(app/ctl_trade.py:416, which drops_charge_young_q, so a young stop may restart at once and flip again within the 60 s) passestest_safety135.py. Head itself behaves correctly. With 4 s spikes at 0/20/40/62/80 s (N=1 and N=2) it stops at 2 s, restarts at 60 s, stops at 64 s and stays latched. Add a check like that.YOUNG_S60 s andGAVE_S15 s are reasoned, not measured (app/modetrade.py:75-79, DECISIONS:22-33). Add a short sensitivity line (for example 30/60/90 s and 10/15/30 s against sims a/b). The rig cut at 91 s shows that the 60 s edge matters.START_W500 W is fine: a give drop of 500 W or less left for at most 15 s moves the period average by 8 W or less.Info
night2, not in the dev's table), head is 1-2 W over base in 5 periods (for example 3 783 vs 3 782 W). These periods are already about 300 W over the aim from the house alone, base too. Rule 2 held the 01:00 start for 25 s, which shifts the later run. Head also charges +0.001-0.002 kWh. This is not a safety issue.What reproduced: all 36 sim runs (base and head: spike, sustain, edge on 1 s and 5 s meters at 2.4/3.0/3.6 kW, owner night and day at N=1 and N=2, night1/2) match the dev's table exactly. Extra cases: a +4 kW load from second 30, 57, 59, 60 or 61 (N=1 and N=2) is cut on the same cycle as base, the charge stays off while the load runs, and the period lands at 3 450.0 W or under. Mutants on
test_safety135.py: base fails 5 checks, delayed cut (decide plus sizing) fails 7, young stop latches fails 1, no hold while the give falls fails 2, young stop on an OPT-15 gap is caught bytest_price_run_fixes.py,young=fallingsurvives (L-1). CI run 1596checks / unitpassed on66d8777. Full local suite on66d8777(pinned admin/goodwe 3b4ff84,GOODWE_REQUIRE_HWSIM=1,--network none): 104 files, 0 failed, 2 123 s;test_safety135,test_split_patches(MAINT-21 tripwire),test_version_gate,test_peakguard,test_safety90,test_price_run_fixesall pass.SAFETY-135 re-review, round 2 (Opus): head
4066624, basea8ecf17Verdict: MERGE-READY as is (
4066624). Nothing is above Low. Round 1's M-1, L-1, L-2 and I-2 are fixed.Round 1 findings
app/ctl_trade.py:410-440). Test part 4 replays the rig's s5b house (identical toSAFETY-136/test/ctl/s5b.json) throughcycle():WAIT_WORDSreplaces the internal word "(young)" in the status row with plain words, which part 5 checks.Docs claims checked
n1_chre-pin claim holds. I diffed all 1 080 rows of base against head: only the reason text differs (118 cycles), and no command moved._off()baselines (RESTART_S/YOUNG_Sset to 0,_charge_stop = (_Every(), -inf)) reproduce the pre-card tree:_give_fallingreturns False;a8ecf17give the same 2 222.87 W; in part 1, the same 2 807.9 W;test_modes_p4's "off = OPT-15 pin" check passes._give_fallingnow uses getattr with a clock-step-back clear. That is fine.test_modes_p3_one_restartcorrectly pins at most one restart, at leastRESTART_Safter the stop, and no start after a second stop.test_price_run_fixes1c keeps its intent: a real guard stop still holds, now for at leastRESTART_S, and then the latch.Low
RESTART_S" rule (app/ctl_trade.py:413-414). If that clause is removed,test_safety135,test_price_run_fixes,test_modes_p3andtest_modes_p4all still pass. Yet the owner night N=2 then lands at 3 465 W: over the 3 450 W aim and 12 W over base. Head's own behaviour is right (3 453 W). This wants a small test, for example a first stop at about 850 s with no restart before the period ends. A follow-up card or the next touch of this code is enough.night1(N=1, house alone 950 W over the aim): head's top period is 4 403 W against base's 4 401 W. I checked the mechanism. At 01:29:59 the heat pump starts while head's restarted charge is still drawing about 530 W, and that charge fades over about 10 s into the new period. That is the known carry-over, which base also has whenever a charge runs at a period end. Here the aim is the month peak (SAFETY-125), so head charges +0.42 kWh up to it. This is not new behaviour and not an owner trade-off, but it supports filing the carry-over card (round 1 I-3).Info
CI and suite
checks / unit (pull_request)success on4066624, andinfra-pin / freshsuccess.4066624(pinned admin/goodwe 3b4ff84,GOODWE_REQUIRE_HWSIM=1,--network none) passed: 104 files, 0 failed, 2 019 s, with a host load of about 20-26.