SAFETY-93: the maintenance charge end hands back smoothly, with no grid charging at a high house
checks / unit (pull_request) Successful in 26m55s
infra-pin / fresh (pull_request) Failing after 1s

At N>=2, when a battery's maintenance charge ended, its watts stayed in the
loop's start point and landed on the other battery (B 0 -> -1600 W in one
cycle, charging from the grid at a 6000 W house). On the cycle a charge
ends, the loop now starts from what the others stood at plus that
battery's new command. N=1 and all identity digests unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:38:42 +02:00
co-authored by Claude Opus 5.5
parent f3e409cf76
commit 05eb93f139
4 changed files with 454 additions and 2 deletions
+31 -2
View File
@@ -75,8 +75,8 @@ from .hass import HomeAssistant
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
MaintResult, arbitrate as maint_arbitrate,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
@@ -994,6 +994,10 @@ class Controller:
self._drain_waits = set()
# SAFETY-91: last cycle's held-back batteries (`_held_back()`).
self._held_was = frozenset()
# SAFETY-93: the batteries in their maintenance charge last cycle,
# and those whose charge ended since the loop last ran.
self._charging_was = frozenset()
self._charge_ended = frozenset()
# 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.
@@ -2368,6 +2372,24 @@ class Controller:
self.target = want
self.i_w = want - self.tuning.gain * (want - float(batt))
def _reseed_charge_end(self, maint_results) -> None:
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
starts from what the others stood at plus that battery's new command
(its HOLD pin, or its last discharge if it is free; a charge stops).
Else the charge's watts stay in the site target and land on the
others: B 0 -> -1600 W in one cycle. DECISIONS/SAFETY-93-charge-end.md.
"""
ended, self._charge_ended = self._charge_ended, frozenset()
if len(self.inverters) < 2 or not ended:
return
for inv, res in zip(self.inverters, maint_results):
if inv.name not in ended:
continue
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
else max(float(inv.target), 0.0))
self.target += now_w - float(inv.target)
self.i_w = self.target
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
@@ -3581,6 +3603,11 @@ class Controller:
self.log_event(msg, hint=messages.hint("maintenance",
phase=machine.phase))
maint_results.append(res)
# SAFETY-93: kept until the loop next runs (a missed poll holds).
_charging = frozenset(inv.name for inv, r in zip(
self.inverters, maint_results) if r.phase == CHARGE)
self._charge_ended |= self._charging_was - _charging
self._charging_was = _charging
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
# so by the time an operator asks "why has pack B not recalibrated" the
@@ -4582,6 +4609,8 @@ class Controller:
# is what seeds it, and dropping it there restarts the ~70 s cycle.
if self.loop_w is not None and self.target != self.loop_w:
self._refusal_end("another writer")
# SAFETY-93: a charge that ended leaves the loop's start point.
self._reseed_charge_end(maint_results)
probe = self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())