SAFETY-93 review: a charge that ends by a stale link or a refusal hands back smoothly too
checks / unit (pull_request) Successful in 27m5s
infra-pin / fresh (pull_request) Failing after 1s

A battery still in CHARGE but dropped by the allocator (stale link, or a
SAFETY-73 refusal contradicting its charge) left its -2000 W in the loop's
start point, and B went 0 -> -1600 W in one cycle. The end is now noted
after the unit claims are filed: in CHARGE, live and not contradicted.
Tests: part 6 (stale link, refusal) and a spy on the guard suspension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 00:10:21 +02:00
co-authored by Claude Opus 5.5
parent 05eb93f139
commit 214051d1a8
3 changed files with 79 additions and 7 deletions
+18 -7
View File
@@ -994,8 +994,8 @@ 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.
# SAFETY-93: the batteries placed 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
@@ -2372,6 +2372,21 @@ class Controller:
self.target = want
self.i_w = want - self.tuning.gain * (want - float(batt))
def _charge_track(self, claims, maint_results) -> None:
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
no longer be placed (stale link, refusal). Kept until the loop runs."""
if len(self.inverters) < 2:
return
live = set(self.live_units())
now = set()
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if res.phase == CHARGE and u in live and not pre_resolve(
[c for c in claims if c.actuator == u]).contradiction:
now.add(inv.name)
self._charge_ended |= self._charging_was - now
self._charging_was = frozenset(now)
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
@@ -3603,11 +3618,6 @@ 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
@@ -4583,6 +4593,7 @@ class Controller:
if self.modes_on:
mode_trade = self._mode_trade(claims, now)
self._mode_trade_was = mode_trade
self._charge_track(claims, maint_results)
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware