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
84 lines
4.9 KiB
Markdown
84 lines
4.9 KiB
Markdown
# 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.
|