At two inverters the drain pins its pack and the site target is split around the pin. Below the pin the other pack had to charge, the allocator refused the opposite directions, and the site sat at 0 W with an ARBITER line every cycle (house below or above the drain rate, or a grid charge). main.py now files a site limit `drain` (lo = what the pin places, at P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds power in the same direction. A drain that cannot give power waits with one log line. With battery modes on, no grid charge while a pack drains, and the other battery's card says why. N=1 is byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.1 KiB
SAFETY-78 - a maintenance drain at two inverters
Status: DECIDED 2026-09-29. Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3
check 5, OPT-11 P3 F4). Tests: test_safety78.py.
The defect. At N>=2 the drain pins its pack (a unit SET at
P_MAINTENANCE) and the site target is split around the pin. Whenever the
site target was below the pin, the other pack had to charge. The allocator
refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to
0 W and logged ARBITER: REFUSED every cycle. That covers a house below the
drain rate, a grid-charge trade, and a house above it too: after a refusal the
loop restarts from 0 W and its 400 W slew step is below the pin again.
The fix. While a pack drains at N>=2, main.py files a site limit
drain, lo = what the pin really places (allocator.pin_w(): the drain
rate inside that pack's own hard limits), at P_MAINTENANCE. So the site
target is never below the pin and the other pack only adds power in the same
direction. Opposite splits stay forbidden.
- The order is the arbiter's own: a safety SET (100) outranks the limit and the pin, so everything parks; the peak floor (50) and the drain floor are both applied, so the higher wins; a trade or the loop below the drain is lifted to it.
- Below the drain rate the drained pack exports the rest, as the drain does at
N=1 (
maintenance.py: "forced discharge, exports the surplus"). Holding it to the house load would make N=2 drain differently from N=1; that is an owner question, not this fix. - A draining pack that can place nothing (a refusal, a stale link) adds no floor, and the wait is logged once per episode.
- With battery modes on, no pack is a grid charger while another drains
(
modetrade.decide(draining=), whycycle), and the other battery's card says why (other_cycle). - Not filed at N=1, where the drain is the site SET itself: byte-identical
(
tools/safety78_identity.py, which hashes the claims too).
Not changed. At N=1 a drain (60) still outranks the peak floor (50), so the peak guard does not add to a drain there. That is pre-existing and kept by the N=1 identity.