Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md
Claude aa501ffec7
checks / unit (pull_request) Successful in 26m5s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-87 review: maintenance pins beside a held battery
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
2026-10-01 10:50:01 +02:00

4.9 KiB

SAFETY-87 - the peak guard uses every battery that can still give

Status: DECIDED 2026-10-01 (owner card). Follows SAFETY-86's "Not changed" list. Tests: test_safety87.py; identity: tools/safety87_identity.py.

The defect. At N>=2 the guard graded the emptiest battery against the highest floor (SAFETY-32 §3). One battery under peak_soc_floor made the whole site floor-limited: the claim asked for 0 W and the reserve alarm fired, even with the other battery full. Measured: a maintenance charge (A at 11 %) raised the alarm 10 times while both batteries gave 2000 W and the quarter landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at 15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86 review; rig case 7a: 4563.8 W).

The fix.

  • decide() and its call are unchanged. At N>=2 Controller._peak_on_able() then grades each battery on its own floor (peakguard.reserve_split()). One at or under it is held. The others that are live and not refusing (SAFETY-73) are able.
  • A floor-limited decision is re-taken on the able batteries (peakguard.place_on_able()): their own permission (min SoC against max floor, among them), and a floor of min(need, able_w). able_w is their rating plus the drain a held battery gives anyway (its maintenance pin).
  • The held batteries weigh 0 in the split while the site gives (arbiter.resolve(reserve=)), so the floor lands on the able ones. During a HOLD, a held battery is not counted among the "others" (_peak_over_hold()), so the holding battery covers the need.
  • Floor-limited now means: some battery is held and the able ones cannot hold the aim. The claim still asks them for able_w. The alarm fires only if they miss the real limit at full power for ALARM_HOLD_S, the same test as 'Load too high' (_reserve_short()), so a one-cycle miss at a quarter's end is not an alarm. With nothing able (all held, or the rest refusing or dropped) it is the old state: 0 W, immediate alarm.
  • The alarm and the row name held, each battery graded on its own floor. The old code named batteries against the site's highest floor.
  • Page: the two peak rows carry runs. A red runs row gives the banner its title without "Not ready", and the Home page is not shown as held at 0 W. Rows that do stop control still outrank it.

How SAFETY-32's concern is still covered. SAFETY-32 took min(SoC) against max(floor) so the guard never sizes a forced discharge a battery's own BMS stops. Now no battery at or under its own floor is asked for the peak: it is graded on its own floor, and an unread SoC or unknown cut-out counts as held. The min/max pairing still decides permission, over the able batteries. The roster verdict decide() echoes (soc, soc_floor) is kept, so SAFETY-32's call-site and reduction checks still bite. What changed is only that one held battery no longer stops the others. One exception, with battery modes on: see Open.

Maintenance pins beside a held battery (review). A draining battery that may still give is raised from its drain rate towards its rating for the peak floor, after the others (_drain_over_reserve(); peak > drain), else the floor's rest landed on the held battery. In a HOLD, a held battery counts its own pin (a drain) among the others, and a held battery pinned to charge stops the raise (never split both ways).

Before -> after (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):

case before after
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 W, B 2488 W, 4051 W A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short)
HOLD, month peak 4000, house 6000 A 0 W, B 2488 W, 3551 W, alarms A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms
both under the reserve alarm alarm (unchanged)
N=1, every scenario byte-identical

Open, for the owner.

  • The loop still draws a held battery (SAFETY-25 option A: the reserve binds the peak guard only). It gives last, after the able ones. So with house 6500 in the charge case, B alone cannot hold the aim, the alarm fires as specified, and the loop then lands the quarter on the aim from A. The text says "cannot keep … alone", not "will go over", so it stays true.
  • Battery modes on, A Off and B held: when B alone can place the floor, OPT-11 keeps A resting and B gives it. When B cannot, A gives first. Making Off give before a held battery in the first case needs a third allocator tier. The same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0. Until then, with modes on, a held battery can give for the peak.
  • The notification and row text live in messages.py, which has no NL layer yet (UX-19). The Home page text, which has NL, is unchanged.

Not changed. N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.