At N≥2 the peak guard now uses every battery that can still give. A battery at or under its own peak reserve is held: it gives nothing for the peak, and the guard's need goes to the live, non-refusing batteries above their reserve. The reserve alarm fires only when those batteries cannot hold the limit. N=1 is byte-identical.
decide() and its call are unchanged. Controller._peak_on_able() grades each battery on its own floor (peakguard.reserve_split()). A floor-limited decision is then re-taken on the able batteries (peakguard.place_on_able()): it checks their own permission (min SoC against max floor, among them) and sets the floor to min(need, able_w). able_w is their rating plus any maintenance drain a held battery gives anyway.
In the split, held batteries weigh 0 while the site gives (arbiter.resolve(reserve=)). During a HOLD they are not counted among "the others" (_peak_over_hold), so the holding battery covers the need.
Floor-limited (and its alarm) now means: some battery is held, and the able batteries miss the real limit at full power for ALARM_HOLD_S. That is the same test as 'Load too high' (_reserve_short), so a one-cycle miss at a quarter's end does not alarm. With nothing able (all held, or the rest refusing or dropped) it is the old state: 0 W and an immediate alarm.
SAFETY-32's concern still holds. No battery at or under its own floor is asked to give for the peak. An unread SoC or an unknown cut-out counts as held. The min/max pairing still decides permission among the able batteries. The roster verdict that decide() echoes is kept, so SAFETY-32's checks still bite. The alarm now names batteries by their own floor; before, it named them against the site's highest floor.
UI. The two peak rows carry runs. While control keeps running, the banner shows the alarm's title without "Not ready", and the Home page no longer says "0 W until the alarm clears" or "An alarm stopped control". Rows that really stop control still outrank them. The N≥2 short-fall text reads "…the other batteries cannot keep this 15-minute period under your 3 500 W peak limit alone." (3 sentences).
Before → after (test_safety87.py, 2 × 2500 W, aim 3450 W)
test_peakguard.py 12g/12i verdict lines and row 23: at N≥2 a battery under its own floor is now held (named in held, weight 0) while the others still shave. Before, the whole site was floor-limited. The floor and SoC echoes, and every slice and pin check, are unchanged.
test_peakguard.py 12h: the full battery now refuses (SAFETY-73), so the alarm still fires and its naming is still graded.
test_peakguard.py part 4 banner: it is now the alarm title without "Not ready", graded on the peak row alone. A stopping row in that fixture outranks it.
test_safety86.py: the stub gains _peak_reserve.
No identity digest changed. The N=1 and N=2 pins in test_safety84/86 and the other identity tests are untouched.
Mutations
Each mutant was applied to a fresh copy with bytecode purged, then 16 test files were run in the CI image (safety87, peakguard, safety86/84/83/81, home, messages, arbiter, alloc, modes ×3, maintenance, batteries_screen, uimodel).
#
file
mutation
result
M01
app/main.py
_peak_on_able disabled (the fix removed)
RED - test_peakguard, test_safety87
M02
app/peakguard.py
reserve_split grades the site floor, not each pack's own
RED - test_peakguard
M03
app/main.py
able: a dropped (not live) battery counted
RED - test_safety87 (added after a first GREEN run: B dropped)
M04
app/main.py
able: a refusing battery counted
RED - test_peakguard, test_safety87
M05
app/main.py
a held battery's drain not counted
RED - test_safety87
M06
app/main.py
able_w = the whole site rating
RED - test_safety87
M07
app/peakguard.py
the able batteries asked for the whole need (no cap)
RED - test_safety87
M08
app/peakguard.py
the able batteries' own permission not graded
RED - test_safety87
M09
app/peakguard.py
boundary: exactly enough counted as short
RED - test_peakguard, test_safety87
M10
app/arbiter.py
held batteries not weighted 0
RED - test_safety87
M11
app/arbiter.py
held weight 0 while charging too
RED - test_safety87
M12
app/arbiter.py
Off vs held tie-break removed
RED - test_safety87
M13
app/main.py
HOLD: a held battery counted among the others
RED - test_safety87
M14
app/main.py
reserve alarm without the load-too-high/hold gate
RED - test_safety87
M15
app/main.py
reserve alarm hold 0 s
RED - test_safety87
M16
app/main.py
alarm graded on the whole site rating
RED - test_safety87
M17
app/main.py
notification names by the site's highest floor (old code)
RED - test_safety87 (added after a first GREEN run: differing cut-outs)
M18
app/main.py
notification: 'will go over' text in the partial case
RED - test_safety87
M19
app/main.py
status: a running peak alarm reads 'Not ready'
RED - test_peakguard, test_safety87
M20
app/homeview.py
Home page held at 0 W on a peak alarm
RED - test_safety87
M21
app/peakguard.py
claim reason 'SoC floor' while the others give
RED - test_safety87
M22
app/main.py
status row red on a one-cycle miss
RED - test_safety87 (added after a first GREEN run: unlatched row)
M23
app/main.py
held weight 0 even when no floor is asked
GREEN - equivalent: able_w is only set by place_on_able() with a floor min(need, able_w) > 0
M24
app/main.py
nothing able: the held battery not named
RED - test_peakguard, test_safety87
M25
app/main.py
able permission graded on the fullest able battery
RED - test_safety87 (added after a first GREEN run: 3-pack pair)
M26
app/main.py
able permission ignores their own cut-outs
RED - test_safety87 (added after a first GREEN run: 3-pack pair)
25 of 26 RED, 1 equivalent. M03, M17, M22, M25 and M26 were GREEN in the first run; the checks that kill them were added in the second commit (re-run on test_safety87). Raw results are in scratch/SAFETY-87/mut/.
Open, for the owner / reviewer
The loop still draws a held battery (SAFETY-25 option A: the reserve binds the guard only). It gives last. In the charge case at house 6500, B alone cannot hold the aim, so the alarm fires as specified; the loop then lands the quarter on the aim using A. The text says "cannot … alone", not "will go over".
With battery modes on, A Off and B held: when B alone can place the floor, OPT-11 keeps A resting and B gives the floor. Fixing that needs a third allocator tier; it is recorded as a check.
Notification and row text live in messages.py, which has no NL layer (UX-19). The Home page strings (EN+NL) are unchanged.
At N=1 the status banner for the two peak alarms changes from "Not ready — …" to the title alone, and the Home page is not held. Control is byte-identical.
SAFETY-85/88/89/90: not touched. SAFETY-88 (charge flapping) is not affected; in the charge case the held battery now gives after B instead of beside it.
## What
At N≥2 the peak guard now uses every battery that can still give. A battery at or under its own peak reserve is **held**: it gives nothing for the peak, and the guard's need goes to the live, non-refusing batteries above their reserve. The reserve alarm fires only when those batteries cannot hold the limit. N=1 is byte-identical.
Record: `goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md`. Tests: `test_safety87.py`. N=1 identity tool: `tools/safety87_identity.py`.
## Design
- `decide()` and its call are unchanged. `Controller._peak_on_able()` grades each battery on its own floor (`peakguard.reserve_split()`). A floor-limited decision is then re-taken on the able batteries (`peakguard.place_on_able()`): it checks their own permission (min SoC against max floor, among them) and sets the floor to `min(need, able_w)`. `able_w` is their rating plus any maintenance drain a held battery gives anyway.
- In the split, held batteries weigh 0 while the site gives (`arbiter.resolve(reserve=)`). During a HOLD they are not counted among "the others" (`_peak_over_hold`), so the holding battery covers the need.
- Floor-limited (and its alarm) now means: some battery is held, and the able batteries miss the real limit at full power for `ALARM_HOLD_S`. That is the same test as 'Load too high' (`_reserve_short`), so a one-cycle miss at a quarter's end does not alarm. With nothing able (all held, or the rest refusing or dropped) it is the old state: 0 W and an immediate alarm.
- **SAFETY-32's concern still holds.** No battery at or under its own floor is asked to give for the peak. An unread SoC or an unknown cut-out counts as held. The min/max pairing still decides permission among the able batteries. The roster verdict that `decide()` echoes is kept, so SAFETY-32's checks still bite. The alarm now names batteries by their own floor; before, it named them against the site's highest floor.
- **UI.** The two peak rows carry `runs`. While control keeps running, the banner shows the alarm's title without "Not ready", and the Home page no longer says "0 W until the alarm clears" or "An alarm stopped control". Rows that really stop control still outrank them. The N≥2 short-fall text reads "…the other batteries cannot keep this 15-minute period under your 3 500 W peak limit alone." (3 sentences).
## Before → after (test_safety87.py, 2 × 2500 W, aim 3450 W)
| case | before (b8ddfd7) | after |
|---|---|---|
| maintenance charge, A 11 %, house 4500 | 3 reserve alarms; quarter 3450 | 0 alarms; 3450 |
| HOLD A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W; 3 alarms | A 2054 W, B 0 W; 0 alarms; 3450 |
| HOLD, house 6500 | A 0, B 2488; 4051 W | A 2500, B 0; 4014 W; 1 alarm (A alone is short) |
| HOLD, month peak 4000, house 6000 | A 0, B 2488; 3551 W; alarms | A 2054, B 0; 3950 (peak − margin); 0 alarms |
| both batteries under the reserve | alarm | alarm (unchanged) |
| N=1 (6 scenarios) | | byte-identical digests |
## Re-pinned checks (behaviour changed on purpose)
- `test_peakguard.py` 12g/12i verdict lines and row 23: at N≥2 a battery under its own floor is now *held* (named in `held`, weight 0) while the others still shave. Before, the whole site was floor-limited. The floor and SoC echoes, and every slice and pin check, are unchanged.
- `test_peakguard.py` 12h: the full battery now refuses (SAFETY-73), so the alarm still fires and its naming is still graded.
- `test_peakguard.py` part 4 banner: it is now the alarm title without "Not ready", graded on the peak row alone. A stopping row in that fixture outranks it.
- `test_safety86.py`: the stub gains `_peak_reserve`.
- No identity digest changed. The N=1 and N=2 pins in test_safety84/86 and the other identity tests are untouched.
## Mutations
Each mutant was applied to a fresh copy with bytecode purged, then 16 test files were run in the CI image (safety87, peakguard, safety86/84/83/81, home, messages, arbiter, alloc, modes ×3, maintenance, batteries_screen, uimodel).
| # | file | mutation | result |
|---|---|---|---|
| M01 | `app/main.py` | _peak_on_able disabled (the fix removed) | RED - test_peakguard, test_safety87 |
| M02 | `app/peakguard.py` | reserve_split grades the site floor, not each pack's own | RED - test_peakguard |
| M03 | `app/main.py` | able: a dropped (not live) battery counted | RED - test_safety87 (added after a first GREEN run: B dropped) |
| M04 | `app/main.py` | able: a refusing battery counted | RED - test_peakguard, test_safety87 |
| M05 | `app/main.py` | a held battery's drain not counted | RED - test_safety87 |
| M06 | `app/main.py` | able_w = the whole site rating | RED - test_safety87 |
| M07 | `app/peakguard.py` | the able batteries asked for the whole need (no cap) | RED - test_safety87 |
| M08 | `app/peakguard.py` | the able batteries' own permission not graded | RED - test_safety87 |
| M09 | `app/peakguard.py` | boundary: exactly enough counted as short | RED - test_peakguard, test_safety87 |
| M10 | `app/arbiter.py` | held batteries not weighted 0 | RED - test_safety87 |
| M11 | `app/arbiter.py` | held weight 0 while charging too | RED - test_safety87 |
| M12 | `app/arbiter.py` | Off vs held tie-break removed | RED - test_safety87 |
| M13 | `app/main.py` | HOLD: a held battery counted among the others | RED - test_safety87 |
| M14 | `app/main.py` | reserve alarm without the load-too-high/hold gate | RED - test_safety87 |
| M15 | `app/main.py` | reserve alarm hold 0 s | RED - test_safety87 |
| M16 | `app/main.py` | alarm graded on the whole site rating | RED - test_safety87 |
| M17 | `app/main.py` | notification names by the site's highest floor (old code) | RED - test_safety87 (added after a first GREEN run: differing cut-outs) |
| M18 | `app/main.py` | notification: 'will go over' text in the partial case | RED - test_safety87 |
| M19 | `app/main.py` | status: a running peak alarm reads 'Not ready' | RED - test_peakguard, test_safety87 |
| M20 | `app/homeview.py` | Home page held at 0 W on a peak alarm | RED - test_safety87 |
| M21 | `app/peakguard.py` | claim reason 'SoC floor' while the others give | RED - test_safety87 |
| M22 | `app/main.py` | status row red on a one-cycle miss | RED - test_safety87 (added after a first GREEN run: unlatched row) |
| M23 | `app/main.py` | held weight 0 even when no floor is asked | GREEN - equivalent: `able_w` is only set by `place_on_able()` with a floor `min(need, able_w)` > 0 |
| M24 | `app/main.py` | nothing able: the held battery not named | RED - test_peakguard, test_safety87 |
| M25 | `app/main.py` | able permission graded on the fullest able battery | RED - test_safety87 (added after a first GREEN run: 3-pack pair) |
| M26 | `app/main.py` | able permission ignores their own cut-outs | RED - test_safety87 (added after a first GREEN run: 3-pack pair) |
25 of 26 RED, 1 equivalent. M03, M17, M22, M25 and M26 were GREEN in the first run; the checks that kill them were added in the second commit (re-run on test_safety87). Raw results are in `scratch/SAFETY-87/mut/`.
## Open, for the owner / reviewer
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds the guard only). It gives last. In the charge case at house 6500, B alone cannot hold the aim, so the alarm fires as specified; the loop then lands the quarter on the aim using A. The text says "cannot … alone", not "will go over".
- With battery modes on, A Off and B held: when B alone can place the floor, OPT-11 keeps A resting and B gives the floor. Fixing that needs a third allocator tier; it is recorded as a check.
- Notification and row text live in `messages.py`, which has no NL layer (UX-19). The Home page strings (EN+NL) are unchanged.
- At N=1 the status banner for the two peak alarms changes from "Not ready — …" to the title alone, and the Home page is not held. Control is byte-identical.
- SAFETY-85/88/89/90: not touched. SAFETY-88 (charge flapping) is not affected; in the charge case the held battery now gives after B instead of beside it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
At N>=2 a battery at or under its own peak reserve is held: it gives
nothing for the peak (weight 0 while the site gives) and the guard's need
is placed on the live, non-refusing batteries above theirs. The guard is
floor-limited, and alarms, only when those cannot hold the limit at full
power for ALARM_HOLD_S. The peak alarms no longer read "Not ready" or hold
the Home page at 0 W, because control keeps running. N=1 is byte-identical.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
A draining battery that may still give is raised from its drain rate
towards its rating for the peak floor while another battery is held
(peak > drain); before, the floor's rest landed on the held battery. In a
HOLD, a held battery's own pin counts among the others: a held drain no
longer over-places the site, and a held charge probe no longer parks it.
Tests: test_safety87.py part 10 (drain beside a held battery, N=3 HOLD,
unknown cut-out and unread level at N=3, a stopping row outranks the
running peak alarm). DECISIONS entry updated, incl. the modes-on exception.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Read: the full diff, the DECISIONS entry, SAFETY-32 §3, the dev report, and the SAFETY-84/86 evidence. I ran the full suite in the CI image on d89c093 (57/57 PASS) and probed the real Controller (probes in scratch/SAFETY-87/review/probe/). Two defects found. I fixed both and pushed aa501ff. One limitation stays open for the owner.
F1: a draining battery beside a held one. FIXED in aa501ff
_peak_on_able() counts an able battery at its full rating. During a maintenance DRAIN that battery is pinned at its drain rate (P_MAINTENANCE SET, above P_PEAK), so it gives only the drain rate. The rest of the peak floor fell on the held battery.
Probe: A held at 15 %, B at 60 % draining at 2000 W, house 8000 W. First cycle: target 2500 W, A 500 W / B 2000 W. A was asked for the peak. The alarm was also graded against 500 W that B was never asked for.
Fix: _drain_over_reserve() is called from _peak_over_drain() at N≥2, only while a battery is held. It raises the draining battery from its drain rate towards its rating for the floor, after the others give (owner order: peak > drain). A site stop still decides alone. A pack pinned to charge stops the raise, so the site is never split both ways. After the fix: A 0 W / B 2500 W (test_safety87 part 10, non-vacuous against the old code).
F2: _peak_over_hold() skipped held batteries entirely. FIXED in aa501ff
The PR left out held batteries as continue. That also dropped their own pin:
Held battery draining (A HOLD, B held and draining 1500 W, floor 2000 W): A's pin was raised to 2000 W and B still gave 1500 W, so 3500 W was placed against a 2000 W target (unmet −1500 W).
Held battery on a SAFETY-73 charge probe (−50 W): A was raised to 2000 W against B charging. Resolution shares {}, unmet 2000 W, so the site parked at 0 W and was split both ways.
Fix: _held_pin() adds a held battery's own pin among "the others" (never more), and a charge pin stops the raise (same rule SAFETY-86 uses for an able pack). Tested: drain gives A 500 W + B 1500 W; probe leaves A at 0 W.
F3: battery modes on, an Off battery vs a held one. OPEN, owner / follow-up card
The dev recorded the N=2 case (A Off, B held, B can place the floor, so B gives it). It is wider than that. At N=3, A Off, B held, C able, floor 4000 W, the split is C 2500 / B 1500 / A 0: the held battery gives for the peak while an Off battery that the owner rule says gives the shortfall stays at 0 W. Cause: _unit_modes() keeps the modes holding whenever the held batteries can place the target, and the held tier is only a weight, so a held battery always beats an Off battery. A fix needs an allocator tier: Off releases before held. That is OPT-11 design, not a SAFETY-87 patch. Modes are off by default and off on the live site. I corrected the DECISIONS claim "no held battery is asked for the peak" to name this exception.
Checked and found right
SAFETY-32: each pack is graded on max(peak_soc_floor, own cut-out). Unread SoC or unknown cut-out counts as held (now also tested at N=3). Min/max pairing among the able batteries. Differing cut-outs name by each pack's own floor.
Alarm:_reserve_short() is SAFETY-81's test on able_w against the real limit, with the two-cycle battery minimum and ALARM_HOLD_S. No alarm on a one-cycle miss. Nothing able gives the old immediate alarm. The text says "cannot … alone" (true, because the loop may still draw the held battery).
runs rows: only the two peak_guard rows, saturated ('Load too high') and reserve. In both, control really runs: the peak claim is a lo limit, not a park. Every stopping state stays above them in the cascade (shadow latch, circulation, sign, any red row without runs, Stopped). I added a check that a stopping row outranks the running alarm, and that the Home page then shows the hold.
Quarter edge, banked peak, margin, drop/refuse mid-quarter, SAFETY-84 charge (no opposite-direction cycle in the charge probe at house 3000/4500/6500), SAFETY-86 HOLD at N=3 with one held (x 500 / y 2500 / z 0, now tested), safety SET wins.
Re-pins (12g/12i/row 23/12h/part-4 banner/test_safety86 stub): each changed only for the stated reason.
N=1 identity:_peak_on_able and _drain_over_reserve both return early at N=1, _peak_reserve() is empty at N=1, and the six digests are unchanged on aa501ff.
Not changed by me (owner question, as the brief asked)
The loop still draws a held battery last (SAFETY-25 option A). The consequence of each option is in the review report.
Mutation results follow in the next comment once the batch finishes.
## SAFETY-87 review, round 1 (reviewer)
Read: the full diff, the DECISIONS entry, SAFETY-32 §3, the dev report, and the SAFETY-84/86 evidence. I ran the full suite in the CI image on d89c093 (57/57 PASS) and probed the real Controller (probes in `scratch/SAFETY-87/review/probe/`). Two defects found. I fixed both and pushed **aa501ff**. One limitation stays open for the owner.
### F1: a draining battery beside a held one. FIXED in aa501ff
`_peak_on_able()` counts an able battery at its full rating. During a maintenance DRAIN that battery is pinned at its drain rate (P_MAINTENANCE SET, above P_PEAK), so it gives only the drain rate. The rest of the peak floor fell on the **held** battery.
Probe: A held at 15 %, B at 60 % draining at 2000 W, house 8000 W. First cycle: target 2500 W, **A 500 W / B 2000 W**. A was asked for the peak. The alarm was also graded against 500 W that B was never asked for.
Fix: `_drain_over_reserve()` is called from `_peak_over_drain()` at N≥2, only while a battery is held. It raises the draining battery from its drain rate towards its rating for the floor, after the others give (owner order: peak > drain). A site stop still decides alone. A pack pinned to charge stops the raise, so the site is never split both ways. After the fix: A 0 W / B 2500 W (test_safety87 part 10, non-vacuous against the old code).
### F2: `_peak_over_hold()` skipped held batteries entirely. FIXED in aa501ff
The PR left out held batteries as `continue`. That also dropped their own pin:
- **Held battery draining** (A HOLD, B held and draining 1500 W, floor 2000 W): A's pin was raised to 2000 W and B still gave 1500 W, so 3500 W was placed against a 2000 W target (unmet −1500 W).
- **Held battery on a SAFETY-73 charge probe** (−50 W): A was raised to 2000 W against B charging. Resolution `shares {}`, unmet 2000 W, so the **site parked at 0 W** and was split both ways.
Fix: `_held_pin()` adds a held battery's own pin among "the others" (never more), and a charge pin stops the raise (same rule SAFETY-86 uses for an able pack). Tested: drain gives A 500 W + B 1500 W; probe leaves A at 0 W.
### F3: battery modes on, an Off battery vs a held one. OPEN, owner / follow-up card
The dev recorded the N=2 case (A Off, B held, B can place the floor, so B gives it). It is wider than that. At **N=3, A Off, B held, C able, floor 4000 W, the split is C 2500 / B 1500 / A 0**: the held battery gives for the peak while an Off battery that the owner rule says gives the shortfall stays at 0 W. Cause: `_unit_modes()` keeps the modes holding whenever the held batteries can place the target, and the held tier is only a weight, so a held battery always beats an Off battery. A fix needs an allocator tier: Off releases before held. That is OPT-11 design, not a SAFETY-87 patch. Modes are off by default and off on the live site. I corrected the DECISIONS claim "no held battery is asked for the peak" to name this exception.
### Checked and found right
- **SAFETY-32:** each pack is graded on `max(peak_soc_floor, own cut-out)`. Unread SoC or unknown cut-out counts as held (now also tested at N=3). Min/max pairing among the able batteries. Differing cut-outs name by each pack's own floor.
- **Alarm:** `_reserve_short()` is SAFETY-81's test on `able_w` against the **real** limit, with the two-cycle battery minimum and ALARM_HOLD_S. No alarm on a one-cycle miss. Nothing able gives the old immediate alarm. The text says "cannot … alone" (true, because the loop may still draw the held battery).
- **`runs` rows:** only the two peak_guard rows, saturated ('Load too high') and reserve. In both, control really runs: the peak claim is a `lo` limit, not a park. Every stopping state stays above them in the cascade (shadow latch, circulation, sign, any red row without `runs`, Stopped). I added a check that a stopping row outranks the running alarm, and that the Home page then shows the hold.
- **Quarter edge, banked peak, margin, drop/refuse mid-quarter, SAFETY-84 charge** (no opposite-direction cycle in the charge probe at house 3000/4500/6500), **SAFETY-86 HOLD at N=3 with one held** (x 500 / y 2500 / z 0, now tested), safety SET wins.
- **Re-pins** (12g/12i/row 23/12h/part-4 banner/test_safety86 stub): each changed only for the stated reason.
- **N=1 identity:** `_peak_on_able` and `_drain_over_reserve` both return early at N=1, `_peak_reserve()` is empty at N=1, and the six digests are unchanged on aa501ff.
### Not changed by me (owner question, as the brief asked)
The loop still draws a held battery last (SAFETY-25 option A). The consequence of each option is in the review report.
Mutation results follow in the next comment once the batch finishes.
I applied my own copies to fresh purged trees of the review head (aa501ff), then ran 13 test files in the CI image (safety87, peakguard, safety86/84/83/81/78, arbiter, alloc, home, messages, uimodel, modes). Raw results: scratch/SAFETY-87/review/mut/.
#
mutation
result
M02
reserve_split grades the site floor, not each pack's own
RED (peakguard, safety87)
M03
a dropped (not live) battery counted as able
RED
M10
held batteries not weighted 0 (arbiter)
RED
M13
HOLD: a held battery counted among the others (re-expressed on aa501ff)
RED (incl. the new N=3 check)
M14
reserve alarm without the load-too-high/hold gate
RED
M17
alarm names by the site's highest floor
RED
M22
status row red on a one-cycle miss
RED
M25
able permission graded on the fullest able battery
RED
R01
unknown cut-out counted as 0 % (so able)
RED
R02
held battery still weighted while the site gives (call site passes reserve=())
reserve alarm without the two-cycle battery minimum
GREEN, equivalent in effect: a one-cycle meter lag cannot last ALARM_HOLD_S (3 cycles), so the alarm cannot fire on it. The filter is graded where it lives (SAFETY-81).
R07
HOLD: a held battery's own pin ignored (= d89c093)
RED
R08
draining able battery not raised (F1 undone)
RED
R09
a held battery's charge pin not refused
RED
R10
a battery refusing only to charge excluded from able
18 of 19 RED, 1 equivalent (R06). The dev's "every rule has a test" held for the sampled dev mutants. Three of my own mutants survived; a4a04d2 adds the checks that kill them. A4a04d2 is test-only.
Owner question (unchanged code): a held battery is still drawn by the loop
At N≥2 a held battery weighs 0, so the loop draws it last, only once the able batteries are at full power. SAFETY-25 option A: the reserve binds the peak guard only. Measured: charge case, A at 11 % held, house 4500 W. The loop ran A to 2010 W beside B's 2500 W, with the quarter on the aim.
Keep A (today): the house is covered and the quarter stays on the aim even when the alarm says "cannot … alone". The bill is lower now. But the held battery is drawn down to its cut-out, so a later peak that evening finds no reserve, and a maintenance charge is undone while it runs (SAFETY-88 flapping).
B: the reserve binds the loop too at N≥2 (close a held battery's discharge, as the pack envelope does at the cut-out): the reserve is kept for the next peak and a maintenance charge holds. The import above the able batteries' rating goes to the grid. That quarter can go over the limit and raise the month peak (capacity tariff) whenever the house load is above the able batteries plus the aim. Then the alarm text "will go over" would be the true one.
## SAFETY-87 review: mutation results (and a4a04d2)
I applied my own copies to fresh purged trees of the review head (aa501ff), then ran 13 test files in the CI image (safety87, peakguard, safety86/84/83/81/78, arbiter, alloc, home, messages, uimodel, modes). Raw results: `scratch/SAFETY-87/review/mut/`.
| # | mutation | result |
|---|---|---|
| M02 | reserve_split grades the site floor, not each pack's own | RED (peakguard, safety87) |
| M03 | a dropped (not live) battery counted as able | RED |
| M10 | held batteries not weighted 0 (arbiter) | RED |
| M13 | HOLD: a held battery counted among the others (re-expressed on aa501ff) | RED (incl. the new N=3 check) |
| M14 | reserve alarm without the load-too-high/hold gate | RED |
| M17 | alarm names by the site's highest floor | RED |
| M22 | status row red on a one-cycle miss | RED |
| M25 | able permission graded on the fullest able battery | RED |
| R01 | unknown cut-out counted as 0 % (so able) | RED |
| R02 | held battery still weighted while the site gives (call site passes `reserve=()`) | RED |
| R03 | every red row treated as "control still runs" | RED (new check: stopping row outranks) |
| R04 | `runs` put on a stopping row (meter stale) | **GREEN on aa501ff**, RED after a4a04d2 |
| R05 | reserve alarm graded on the aim, not the real limit | **GREEN on aa501ff**, RED after a4a04d2 |
| R06 | reserve alarm without the two-cycle battery minimum | GREEN, equivalent in effect: a one-cycle meter lag cannot last ALARM_HOLD_S (3 cycles), so the alarm cannot fire on it. The filter is graded where it lives (SAFETY-81). |
| R07 | HOLD: a held battery's own pin ignored (= d89c093) | RED |
| R08 | draining able battery not raised (F1 undone) | RED |
| R09 | a held battery's charge pin not refused | RED |
| R10 | a battery refusing only to *charge* excluded from able | **GREEN on aa501ff**, RED after a4a04d2 |
| R11 | enough able power still reported floor-limited | RED (peakguard, safety87) |
18 of 19 RED, 1 equivalent (R06). The dev's "every rule has a test" held for the sampled dev mutants. Three of my own mutants survived; a4a04d2 adds the checks that kill them. A4a04d2 is test-only.
### Owner question (unchanged code): a held battery is still drawn by the loop
At N≥2 a held battery weighs 0, so the loop draws it **last**, only once the able batteries are at full power. SAFETY-25 option A: the reserve binds the peak guard only. Measured: charge case, A at 11 % held, house 4500 W. The loop ran A to 2010 W beside B's 2500 W, with the quarter on the aim.
- **Keep A (today):** the house is covered and the quarter stays on the aim even when the alarm says "cannot … alone". The bill is lower now. But the held battery is drawn down to its cut-out, so a later peak that evening finds no reserve, and a maintenance charge is undone while it runs (SAFETY-88 flapping).
- **B: the reserve binds the loop too at N≥2** (close a held battery's discharge, as the pack envelope does at the cut-out): the reserve is kept for the next peak and a maintenance charge holds. The import above the able batteries' rating goes to the grid. That quarter can go over the limit and raise the month peak (capacity tariff) whenever the house load is above the able batteries plus the aim. Then the alarm text "will go over" would be the true one.
_drain_over_reserve() only acts while a battery is held and a positive peak floor stands, so every N≥2 run without a held battery is untouched (all identity tests unchanged). It runs before _peak_over_hold(), so a drain gives before a HOLD (SAFETY-86's order). The raised pin never exceeds the floor minus the others, so the site target (≥ the floor) always covers the pins and nothing is split both ways. SAFETY-78's drain floor stays at or under the pin.
b4d25dd: one DOCS line (maintenance_discharge_w). The drain rate may now also rise for the peak at N≥2 while another battery is at its reserve.
Local: 57/57 test files PASS in the CI image on b4d25dd. N=1 digests unchanged.
finding
status
F1: draining able battery counted at its rating, so the floor's rest fell on the held battery
All in-scope points are fixed. I will merge once required CI is green on b4d25dd.
## SAFETY-87 review, round 2 (final): head b4d25dd
I re-reviewed my own changes (aa501ff, a4a04d2, b4d25dd):
- `_drain_over_reserve()` only acts while a battery is held and a positive peak floor stands, so every N≥2 run without a held battery is untouched (all identity tests unchanged). It runs before `_peak_over_hold()`, so a drain gives before a HOLD (SAFETY-86's order). The raised pin never exceeds the floor minus the others, so the site target (≥ the floor) always covers the pins and nothing is split both ways. SAFETY-78's drain floor stays at or under the pin.
- b4d25dd: one DOCS line (`maintenance_discharge_w`). The drain rate may now also rise for the peak at N≥2 while another battery is at its reserve.
- Local: 57/57 test files PASS in the CI image on b4d25dd. N=1 digests unchanged.
| finding | status |
|---|---|
| F1: draining able battery counted at its rating, so the floor's rest fell on the held battery | fixed (aa501ff) |
| F2: HOLD ignored a held battery's own pin (over-placement; charge probe parked the site at 0 W) | fixed (aa501ff) |
| F3: modes on, Off vs held (N=2 and N=3) | open: owner / follow-up OPT-11 card, recorded in DECISIONS |
| T: three surviving review mutants (R04, R05, R10) | fixed (a4a04d2) |
| Held battery drawn by the loop (SAFETY-25 A vs B) | owner question, not changed |
All in-scope points are fixed. I will merge once required CI is green on b4d25dd.
admin
merged commit 935878193b into release/1.02026-10-01 12:06:42 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
At N≥2 the peak guard now uses every battery that can still give. A battery at or under its own peak reserve is held: it gives nothing for the peak, and the guard's need goes to the live, non-refusing batteries above their reserve. The reserve alarm fires only when those batteries cannot hold the limit. N=1 is byte-identical.
Record:
goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md. Tests:test_safety87.py. N=1 identity tool:tools/safety87_identity.py.Design
decide()and its call are unchanged.Controller._peak_on_able()grades each battery on its own floor (peakguard.reserve_split()). A floor-limited decision is then re-taken on the able batteries (peakguard.place_on_able()): it checks their own permission (min SoC against max floor, among them) and sets the floor tomin(need, able_w).able_wis their rating plus any maintenance drain a held battery gives anyway.arbiter.resolve(reserve=)). During a HOLD they are not counted among "the others" (_peak_over_hold), so the holding battery covers the need.ALARM_HOLD_S. That is the same test as 'Load too high' (_reserve_short), so a one-cycle miss at a quarter's end does not alarm. With nothing able (all held, or the rest refusing or dropped) it is the old state: 0 W and an immediate alarm.decide()echoes is kept, so SAFETY-32's checks still bite. The alarm now names batteries by their own floor; before, it named them against the site's highest floor.runs. While control keeps running, the banner shows the alarm's title without "Not ready", and the Home page no longer says "0 W until the alarm clears" or "An alarm stopped control". Rows that really stop control still outrank them. The N≥2 short-fall text reads "…the other batteries cannot keep this 15-minute period under your 3 500 W peak limit alone." (3 sentences).Before → after (test_safety87.py, 2 × 2500 W, aim 3450 W)
b8ddfd7)Re-pinned checks (behaviour changed on purpose)
test_peakguard.py12g/12i verdict lines and row 23: at N≥2 a battery under its own floor is now held (named inheld, weight 0) while the others still shave. Before, the whole site was floor-limited. The floor and SoC echoes, and every slice and pin check, are unchanged.test_peakguard.py12h: the full battery now refuses (SAFETY-73), so the alarm still fires and its naming is still graded.test_peakguard.pypart 4 banner: it is now the alarm title without "Not ready", graded on the peak row alone. A stopping row in that fixture outranks it.test_safety86.py: the stub gains_peak_reserve.Mutations
Each mutant was applied to a fresh copy with bytecode purged, then 16 test files were run in the CI image (safety87, peakguard, safety86/84/83/81, home, messages, arbiter, alloc, modes ×3, maintenance, batteries_screen, uimodel).
app/main.pyapp/peakguard.pyapp/main.pyapp/main.pyapp/main.pyapp/main.pyapp/peakguard.pyapp/peakguard.pyapp/peakguard.pyapp/arbiter.pyapp/arbiter.pyapp/arbiter.pyapp/main.pyapp/main.pyapp/main.pyapp/main.pyapp/main.pyapp/main.pyapp/main.pyapp/homeview.pyapp/peakguard.pyapp/main.pyapp/main.pyable_wis only set byplace_on_able()with a floormin(need, able_w)> 0app/main.pyapp/main.pyapp/main.py25 of 26 RED, 1 equivalent. M03, M17, M22, M25 and M26 were GREEN in the first run; the checks that kill them were added in the second commit (re-run on test_safety87). Raw results are in
scratch/SAFETY-87/mut/.Open, for the owner / reviewer
messages.py, which has no NL layer (UX-19). The Home page strings (EN+NL) are unchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
SAFETY-87 review, round 1 (reviewer)
Read: the full diff, the DECISIONS entry, SAFETY-32 §3, the dev report, and the SAFETY-84/86 evidence. I ran the full suite in the CI image on
d89c093(57/57 PASS) and probed the real Controller (probes inscratch/SAFETY-87/review/probe/). Two defects found. I fixed both and pushedaa501ff. One limitation stays open for the owner.F1: a draining battery beside a held one. FIXED in
aa501ff_peak_on_able()counts an able battery at its full rating. During a maintenance DRAIN that battery is pinned at its drain rate (P_MAINTENANCE SET, above P_PEAK), so it gives only the drain rate. The rest of the peak floor fell on the held battery.Probe: A held at 15 %, B at 60 % draining at 2000 W, house 8000 W. First cycle: target 2500 W, A 500 W / B 2000 W. A was asked for the peak. The alarm was also graded against 500 W that B was never asked for.
Fix:
_drain_over_reserve()is called from_peak_over_drain()at N≥2, only while a battery is held. It raises the draining battery from its drain rate towards its rating for the floor, after the others give (owner order: peak > drain). A site stop still decides alone. A pack pinned to charge stops the raise, so the site is never split both ways. After the fix: A 0 W / B 2500 W (test_safety87 part 10, non-vacuous against the old code).F2:
_peak_over_hold()skipped held batteries entirely. FIXED inaa501ffThe PR left out held batteries as
continue. That also dropped their own pin:shares {}, unmet 2000 W, so the site parked at 0 W and was split both ways.Fix:
_held_pin()adds a held battery's own pin among "the others" (never more), and a charge pin stops the raise (same rule SAFETY-86 uses for an able pack). Tested: drain gives A 500 W + B 1500 W; probe leaves A at 0 W.F3: battery modes on, an Off battery vs a held one. OPEN, owner / follow-up card
The dev recorded the N=2 case (A Off, B held, B can place the floor, so B gives it). It is wider than that. At N=3, A Off, B held, C able, floor 4000 W, the split is C 2500 / B 1500 / A 0: the held battery gives for the peak while an Off battery that the owner rule says gives the shortfall stays at 0 W. Cause:
_unit_modes()keeps the modes holding whenever the held batteries can place the target, and the held tier is only a weight, so a held battery always beats an Off battery. A fix needs an allocator tier: Off releases before held. That is OPT-11 design, not a SAFETY-87 patch. Modes are off by default and off on the live site. I corrected the DECISIONS claim "no held battery is asked for the peak" to name this exception.Checked and found right
max(peak_soc_floor, own cut-out). Unread SoC or unknown cut-out counts as held (now also tested at N=3). Min/max pairing among the able batteries. Differing cut-outs name by each pack's own floor._reserve_short()is SAFETY-81's test onable_wagainst the real limit, with the two-cycle battery minimum and ALARM_HOLD_S. No alarm on a one-cycle miss. Nothing able gives the old immediate alarm. The text says "cannot … alone" (true, because the loop may still draw the held battery).runsrows: only the two peak_guard rows, saturated ('Load too high') and reserve. In both, control really runs: the peak claim is alolimit, not a park. Every stopping state stays above them in the cascade (shadow latch, circulation, sign, any red row withoutruns, Stopped). I added a check that a stopping row outranks the running alarm, and that the Home page then shows the hold._peak_on_ableand_drain_over_reserveboth return early at N=1,_peak_reserve()is empty at N=1, and the six digests are unchanged onaa501ff.Not changed by me (owner question, as the brief asked)
The loop still draws a held battery last (SAFETY-25 option A). The consequence of each option is in the review report.
Mutation results follow in the next comment once the batch finishes.
SAFETY-87 review: mutation results (and
a4a04d2)I applied my own copies to fresh purged trees of the review head (
aa501ff), then ran 13 test files in the CI image (safety87, peakguard, safety86/84/83/81/78, arbiter, alloc, home, messages, uimodel, modes). Raw results:scratch/SAFETY-87/review/mut/.aa501ff)reserve=())runsput on a stopping row (meter stale)aa501ff, RED aftera4a04d2aa501ff, RED aftera4a04d2d89c093)aa501ff, RED aftera4a04d218 of 19 RED, 1 equivalent (R06). The dev's "every rule has a test" held for the sampled dev mutants. Three of my own mutants survived;
a4a04d2adds the checks that kill them. A4a04d2 is test-only.Owner question (unchanged code): a held battery is still drawn by the loop
At N≥2 a held battery weighs 0, so the loop draws it last, only once the able batteries are at full power. SAFETY-25 option A: the reserve binds the peak guard only. Measured: charge case, A at 11 % held, house 4500 W. The loop ran A to 2010 W beside B's 2500 W, with the quarter on the aim.
SAFETY-87 review, round 2 (final): head
b4d25ddI re-reviewed my own changes (
aa501ff,a4a04d2,b4d25dd):_drain_over_reserve()only acts while a battery is held and a positive peak floor stands, so every N≥2 run without a held battery is untouched (all identity tests unchanged). It runs before_peak_over_hold(), so a drain gives before a HOLD (SAFETY-86's order). The raised pin never exceeds the floor minus the others, so the site target (≥ the floor) always covers the pins and nothing is split both ways. SAFETY-78's drain floor stays at or under the pin.b4d25dd: one DOCS line (maintenance_discharge_w). The drain rate may now also rise for the peak at N≥2 while another battery is at its reserve.b4d25dd. N=1 digests unchanged.aa501ff)aa501ff)a4a04d2)All in-scope points are fixed. I will merge once required CI is green on
b4d25dd.