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
+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.