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
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>=2Controller._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 ofmin(need, able_w).able_wis 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 forALARM_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 redrunsrow 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.