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
+12 -5
View File
@@ -344,6 +344,16 @@ class _Narrowed:
self.lo, self.hi = lo, hi
def pin_w(b):
"""The watts a unit's own SET places once held inside its hard limits,
as `plan()` places a standing pin, or None when the unit has no SET."""
pv = getattr(b, "pinned_w", None)
if getattr(b, "gate", None) is None or pv is None:
return None
lo, hi = _int_envelope(b)
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
def loop_bound(bounds, live=None):
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
@@ -364,11 +374,8 @@ def loop_bound(bounds, live=None):
continue
if getattr(b, "contradiction", False):
continue
pv = getattr(b, "pinned_w", None)
if getattr(b, "gate", None) is not None and pv is not None:
lo, hi = _int_envelope(b)
p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)),
lo, hi))
p = pin_w(b)
if p is not None:
lo_sum += min(0.0, p)
hi_sum += max(0.0, p)
continue
+6
View File
@@ -184,6 +184,12 @@ CLAIM_SCOPE = {
# the banner and the claim an install has had since SAFETY-02 shipped.
# `Controller.cycle()` does not even arm the site detector there.
"sign_site": SCOPE_SITE,
# main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At
# N>=2, while a pack's maintenance drain is pinned, the site target may
# not go below what that pin places: below it a sibling would have to
# charge against the drain (circulation, refused, 0 W). A site `lo`,
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
"drain": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
+12 -3
View File
@@ -640,6 +640,11 @@ def battery_rows(c, shadow, running, held=False):
# carries its share (SAFETY-73)?
other = next((o.name for o in c.inverters if o is not inv
and refusing(c, o) > 0 and not _off(c, o)), None)
# SAFETY-78: another battery drains for its monthly cycle, so this
# one never charges now.
cycling = next((o.name for j, o in enumerate(c.inverters)
if o is not inv and j < len(c.maints)
and c.maints[j].phase == M_DRAIN), None)
role = None if tick is None else tick.roles.get(inv.name)
# Only once a cycle has run with modes on (`tick`): before that
# nothing is known, so nothing is claimed about the prices.
@@ -655,7 +660,8 @@ def battery_rows(c, shadow, running, held=False):
phase=phase, shadow=shadow, running=running, held=held,
refused=refusing(c, inv), peak=peak,
giving=flow(inv.batt) == "giving", other_refuses=other,
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at))
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at),
other_cycle=cycling)
rows.append({
"name": inv.name,
"level_pct": None if inv.soc is None else float(inv.soc),
@@ -673,7 +679,8 @@ def battery_rows(c, shadow, running, held=False):
# UX-13 P3: why it does what "now" says, from what the add-on
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
"why_key": why,
"why": why_words(why, name=other, at=at),
"why": why_words(why, name=cycling if why == "other_cycle"
else other, at=at),
})
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
@@ -692,7 +699,7 @@ def battery_rows(c, shadow, running, held=False):
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
held, refused, peak, sf_ahead, giving=False,
other_refuses=None, role=None, plan=None,
has_at=False) -> str:
has_at=False, other_cycle=None) -> str:
"""The key of one battery's "why" line (`home_words.why`), most urgent
first. Pure, so every branch is tested without a controller."""
if not reading:
@@ -712,6 +719,8 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
return "refuses_give" if refused > 0 else "refuses_charge"
if peak:
return "peak"
if other_cycle:
return "other_cycle"
if giving and other_refuses:
return "other_refuses"
if not modes_on:
+49 -2
View File
@@ -71,7 +71,7 @@ from .hass import HomeAssistant
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
DRAIN, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
@@ -948,6 +948,9 @@ class Controller:
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
# SAFETY-78: names of draining packs that cannot give power now, so
# the "waits" line is logged once, not every cycle.
self._drain_waits = set()
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -2161,7 +2164,8 @@ class Controller:
headroom_w=self.arb_headroom_w, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s)
done_charge=done_c, done_sell=done_s,
draining=any(r.phase == DRAIN for r in maint_results or ()))
# A charge the guard or the headroom stopped stays off this period.
if was and self.mode_tick.action != modetrade.CHARGE and \
self.mode_tick.why in ("peak", "headroom"):
@@ -2383,6 +2387,42 @@ class Controller:
except Exception: # noqa: BLE001
_LOG.exception("Home Assistant command could not be handled")
def _drain_floor(self, claims, maint_results):
"""SAFETY-78: the site `lo` for a pinned maintenance drain, or None.
At N>=2 the drain pins its pack and the site target is split around
the pin. Below the pin a sibling would have to charge (opposite
directions, refused, 0 W, and the drain never moves). So the site
target may not go below what the pin places: the drained pack covers
the house up to its drain rate and exports the rest, as at N=1, and
a sibling only ever adds power in the same direction. At N=1 the
drain is the site SET itself, so nothing is filed.
A draining pack whose own limits leave it nothing to give (a refusal,
a stale link) adds no floor, and that is logged once.
"""
if len(self.inverters) < 2:
return None
live = set(self.live_units())
floor, waits = 0.0, set()
for inv, res in zip(self.inverters, maint_results):
if res.phase != DRAIN or not res.owns_setpoint \
or not (res.setpoint_w or 0.0) > 0.0:
continue
u = self.actuator_of("maintenance", inv)
b = pre_resolve([c for c in claims if c.actuator == u])
p = (None if u not in live or b.contradiction
or b.pinned_by != "maintenance" else allocator.pin_w(b))
if p is not None and p > 0.0:
floor += p
else:
waits.add(inv.name)
for name in sorted(waits - self._drain_waits):
self.log_event(f"maintenance: {name} cannot give power now, so "
f"its monthly cycle waits",
hint=messages.hint("maint_waits", unit=name))
self._drain_waits = waits
return floor if floor > 0.0 else None
def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE:
@@ -4091,6 +4131,13 @@ class Controller:
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
# --- SAFETY-78: a pinned drain is a floor under the site target -----
# After every unit claim, so the floor is what the pin really places.
drain = self._drain_floor(claims, maint_results)
if drain is not None:
claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain,
reason="maintenance drain",
actuator=self.actuator_of("drain")))
# --- OPT-11 P3: the site trade, after every unit claim --------------
# Sized from what the batteries may place with their modes and hard
# limits (the loop bound), so none is asked for watts it may not give.
+1
View File
@@ -929,6 +929,7 @@ HINTS = {
"pack_mode": "{unit}: battery mode changed",
"pack_sell": "{unit}: selling switched",
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
"maint_waits": "{unit} cannot give power now, so its monthly cycle waits",
"mode_fault": "{unit}: battery mode ignored because of a software fault",
}
+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
+1
View File
@@ -327,6 +327,7 @@
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
"covers_house": "It gives power to cover the house.",
"other_refuses": "{name} refuses to give power, so this battery takes over.",
"other_cycle": "{name} is emptying for its monthly cycle. This one does not charge.",
"grid_charge": "Prices are low, so it charges from the grid.",
"rests_charge": "Another battery charges from the grid, so it rests.",
"selling": "Prices are high, so it sells to the grid.",
+1
View File
@@ -269,6 +269,7 @@
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
"covers_house": "Ze levert stroom om het huis te dekken.",
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over.",
"other_cycle": "{name} loopt leeg voor haar maandelijkse cyclus. Deze laadt niet.",
"grid_charge": "De prijs is laag, dus ze laadt van het net.",
"rests_charge": "Een andere batterij laadt van het net, dus deze rust.",
"selling": "De prijs is hoog, dus ze verkoopt aan het net.",