SAFETY-78: maintenance drain at N=2 no longer stalls
checks / unit (pull_request) Failing after 17m13s
infra-pin / fresh (pull_request) Successful in 2s

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>
This commit is contained in:
Claude
2026-09-29 19:52:51 +02:00
parent 5a4cdc273e
commit 5d27f8496f
18 changed files with 653 additions and 95 deletions
+9 -3
View File
@@ -39,7 +39,9 @@ Rules that keep this safe (tests: test_modes_p3.py):
* a battery out of a run (full, or at the floor) stays out until the run
ends, so the ceiling and the floor do not re-arm within one run;
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
run in that direction: the refusal wins over the run.
run in that direction: the refusal wins over the run;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
from dataclasses import dataclass
@@ -118,7 +120,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=()) -> Tick:
blocked=False, done_charge=(), done_sell=(),
draining=False) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
@@ -129,7 +132,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
`house_w` is the measured house (grid + batteries), or None.
`charging_now` says last tick charged (then any headroom above 0 keeps
it going); `blocked` that the guard or the headroom stopped a charge in
this 15-minute period.
this 15-minute period. `draining` says a battery drains for its
monthly cycle, so none may charge from the grid (why "cycle").
"""
plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
@@ -138,6 +142,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
why = None
ch = chargers(packs, ceiling_pct, done_charge) if in_cheap else []
if ch and draining:
ch, why = [], "cycle"
charging = False
if ch:
need = 0.0 if charging_now else START_W