Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
Conflicts: modetrade.decide() takes both the day plan (targets) and SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle ahead of the plan's word (test added). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,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,
|
||||
@@ -979,6 +979,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.
|
||||
@@ -2248,7 +2251,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, targets=targets)
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
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"):
|
||||
@@ -2473,6 +2477,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:
|
||||
@@ -4207,6 +4247,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