# 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.