At N=1 the drain is the site SET at P_MAINTENANCE (60), so the peak floor
(P_PEAK, 50) was not applied and the house imported what the drain left.
Where the floor asks for more than the drain rate, main.py now refiles the
peak claim at P_MAINTENANCE, so it wins; safety SETs still park everything.
Nothing else changes: safety78_identity --diff against 35cdffe changes only
cycles where the guard wanted more than the drain; N=2 is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
44 lines
2.5 KiB
Markdown
44 lines
2.5 KiB
Markdown
# 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=)`, why `cycle`), and the other battery's card
|
|
says why (`other_cycle`). Not while the drain waits: no floor is filed
|
|
then, so the other battery may charge from solar.
|
|
- 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 (measured in the PR #154 review, follow-ups).**
|
|
- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported
|
|
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
|
|
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
|
|
charging pack's `hi = -floor` against the loop asking the other to give is
|
|
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
|
|
the peak floor's `lo` (0 W), so it is not this card.
|