SAFETY-87: peak guard uses every battery that can still give #162

Merged
admin merged 5 commits from safety-87-guard-uses-able-batteries into release/1.0 2026-10-01 12:06:42 +02:00
Owner

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.ai/code/session_019DBUfvndgHwaV339Fvd2gr

## 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
admin added 1 commit 2026-10-01 09:30:02 +02:00
SAFETY-87: peak guard uses every battery that can still give
checks / unit (pull_request) Successful in 25m45s
infra-pin / fresh (pull_request) Successful in 1s
d0db5b8c39
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
admin added 1 commit 2026-10-01 09:55:32 +02:00
SAFETY-87: grade the surviving mutants (dropped battery, own-floor naming, pair permission, latched row)
checks / unit (pull_request) Successful in 24m56s
infra-pin / fresh (pull_request) Successful in 2s
d89c093318
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-01 10:50:09 +02:00
SAFETY-87 review: maintenance pins beside a held battery
checks / unit (pull_request) Successful in 26m5s
infra-pin / fresh (pull_request) Successful in 1s
aa501ffec7
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
Author
Owner

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.

## 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.
admin added 1 commit 2026-10-01 11:07:46 +02:00
SAFETY-87 review: grade the three surviving review mutants
checks / unit (pull_request) Successful in 25m5s
infra-pin / fresh (pull_request) Successful in 1s
a4a04d203c
A battery refusing only to charge stays able; the reserve alarm is graded
on the real limit, not the aim; 'control still runs' is set on the two
peak rows only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Author
Owner

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.
## 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.
admin added 1 commit 2026-10-01 11:08:43 +02:00
SAFETY-87 review: DOCS, the drain rate may rise for the peak at N>=2
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 24m53s
b4d25dd485
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Author
Owner

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.

## 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.0 2026-10-01 12:06:42 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#162