SAFETY-78: maintenance drain at N=2 no longer stalls
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user