SAFETY-96: a battery the loop cannot move does not freeze it
checks / unit (pull_request) Successful in 31m44s
infra-pin / fresh (pull_request) Successful in 2s

At N>=2 a battery out of the split (stale link), with refused writes, or
settling after a step the loop did not make (a charge end, a rejoin) now
counts in the freeze test at its command, not its reading. Its real power
still reaches the loop through the meter. At a drop its share leaves the
loop's start point (the others take over one slew step a cycle); at a
rejoin it enters at its reading. No refusal is learned while a battery's
writes are refused. N=1 and the 46 identity digests are byte-identical.

test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at
its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-06 04:02:03 +02:00
co-authored by Claude Opus 5.5
parent 1bf6b7f9a7
commit 88b9cd470a
5 changed files with 675 additions and 1 deletions
+80
View File
@@ -1025,6 +1025,12 @@ class Controller:
# cycle, and those whose charge ended since the loop last ran.
self._charging_was = frozenset()
self._charge_ended = frozenset()
# SAFETY-96: last cycle's commandable batteries, this cycle's start
# point per battery where a reseed moved it, and batteries settling
# after a step the loop did not make ({name: since, monotonic}).
self._live_was = None
self._loop_start = {}
self._settle = {}
# 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.
@@ -1393,6 +1399,11 @@ class Controller:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if inv.rejected_since is not None:
# SAFETY-96: its writes are refused, so its board may have
# gone to 0 W on its own; that is no refusal by the battery.
inv.ref_count = 0
continue
sign = self._gave_nothing(inv)
if sign:
self._unit_refusal_start(
@@ -2456,9 +2467,69 @@ class Controller:
continue
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
else max(float(inv.target), 0.0))
if not inv.is_live(datetime.now(timezone.utc))[0]:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
self.target += now_w - float(inv.target)
# SAFETY-96: it settles from its charge to that command.
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
self.i_w = self.target
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
link) leaves the loop's start point, so the others take over at one
slew step a cycle instead of taking its share at once. One that comes
back enters it at its reading, and every battery settles while the
split is made again (`_held_out_w`).
DECISIONS/SAFETY-96-dropped-battery.md.
"""
was, self._live_was = self._live_was, frozenset(live)
if len(self.inverters) < 2 or was is None or was == live:
return
for inv in self.inverters:
if inv.name in self._charge_ended:
continue # SAFETY-93's edge: `_reseed_charge_end()`
if inv.name in was and inv.name not in live:
now_w = 0.0
elif inv.name in live and inv.name not in was:
now_w = float(inv.batt)
# The split is made again, so every battery's command steps.
self._settle.update(
{u: self._now_mono for u in live})
else:
continue
self.target += now_w - float(inv.target)
self._loop_start[inv.name] = now_w
self.i_w = self.target
def _held_out_w(self, live) -> float:
"""SAFETY-96, N>=2: what to add to the site sum so that a battery
the loop cannot move right now counts at its command, not at its
reading. That is a battery out of the split, one whose writes are
refused, or one settling after a step the loop did not make (a charge
end, a rejoin) until its reading is within the deadband of its
command or PLANT_SETTLE_DOWN_S has passed. Its real power still
reaches the loop through the meter, so it is neither lost nor counted
twice; only the saturation freeze stops seeing it as the loop's.
"""
if len(self.inverters) < 2:
return 0.0
out = 0.0
for inv in self.inverters:
cmd = self._loop_start.get(inv.name, float(inv.target))
since = self._settle.get(inv.name)
age = (None if since is None or self._now_mono is None
else self._now_mono - since)
if since is not None and (
age is None or age >= _ctl.PLANT_SETTLE_DOWN_S
or (age > 0 and abs(float(inv.batt) - cmd)
< self.tuning.deadband_w)):
del self._settle[inv.name]
if (inv.name not in live or inv.rejected_since is not None
or inv.name in self._settle):
out += cmd - float(inv.batt)
return out
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
@@ -4730,6 +4801,8 @@ class Controller:
mode_trade = self._mode_trade(claims, now)
self._mode_trade_was = mode_trade
self._charge_track(claims, maint_results)
_live96 = set(self.live_units())
self._loop_start = {}
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
@@ -4756,6 +4829,9 @@ 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-96: a battery that dropped out of the split leaves the
# loop's start point (SAFETY-93's own if its charge ended too).
self._reseed_drop(_live96)
# SAFETY-93: a charge that ended leaves the loop's start point.
self._reseed_charge_end(maint_results)
probe = self._refusal_probe()
@@ -4775,6 +4851,9 @@ class Controller:
# level: the two halves of §6.2's identity break in opposite
# directions. See `site_batt_w` and test_alloc.py part 8.
site_batt = self.site_batt_w
# SAFETY-96: a battery the loop cannot move counts at its command,
# so its own power does not freeze the loop (the meter has it).
site_batt += self._held_out_w(_live96)
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
# AGAINST. `self.target` is reassigned further down this method, so
# a `decision.i_w - self.target` written at publish time would
@@ -4883,6 +4962,7 @@ class Controller:
# Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0
self.loop_w = None
self._live_was = frozenset(_live96)
# TEL-10, and it is the same zero the line above writes rather than
# a second opinion about it. ⚠️ `not-running` rather than a stale
# `tracking`: a reason row that keeps repeating the last word the