SAFETY-103: no swing on old battery readings after a drain ends
checks / unit (pull_request) Failing after 35m9s
infra-pin / fresh (pull_request) Successful in 2s

While a battery settles from its ended drain (SAFETY-95), the house point
was grid + every battery's reading. A register can be 10 s older than the
meter, so B swung 860 -> 1650 -> 870 W on A's old reading (rig check 5).

Now the house point is held and moved only as far as the meter proves:
each battery is between its last changed reading and its commands since
(a reading that changed this cycle, or a refused side, as it reads).
N=1 and 1 s registers are byte-identical to release/1.0.

DECISIONS/SAFETY-103-old-readings-after-a-drain.md, test_safety103.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 02:25:55 +02:00
co-authored by Claude Opus 5.5
parent 451729c9b5
commit 138e45b53f
5 changed files with 727 additions and 4 deletions
+54 -3
View File
@@ -1031,6 +1031,11 @@ class Controller:
# SAFETY-95: batteries settling after their drain ended; meanwhile
# the loop's error counts every battery at its command.
self._drain_tail = set()
# SAFETY-103: per battery, the range its power can be in since its
# reading last changed (that reading and every command since), and
# the house point held while a drain's tail settles.
self._read_span = {}
self._drain_house = None
# 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}).
@@ -2527,13 +2532,56 @@ class Controller:
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
the others' climb, so the loop would chase them (B down, then up
again). Meanwhile the batteries count at the site command: the error
is the house's own point minus the loop's start."""
is the house's own point minus the loop's start.
SAFETY-103: a reading can be up to a register period (10 s) older
than the meter, so the house point is held, and moved only as far as
the meter proves: to the nearest value the batteries can be at (each
between its last changed reading and its commands since; a reading
that changed this cycle, or the side a battery refuses, is taken as
it reads). DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
"""
grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail:
self._drain_house = None
return grid
return grid - float(self.target) + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
aim = self.tuning.target_grid_w
lo = hi = grid - aim
for inv in self.inverters:
if inv.name not in live:
continue
r = float(inv.batt)
span = self._read_span.get(inv.name)
if (span is None or span[2] != inv.batt_fresh.changed_mono
or span[2] == self._now_mono):
span = (r, r)
# A battery that refuses one way (SAFETY-73) is not heading
# that way, whatever it was asked: that side is its reading.
if (inv.refused or 0) > 0:
span = (min(span[0], r), r)
elif (inv.refused or 0) < 0:
span = (r, max(span[1], r))
lo, hi = lo + span[0], hi + span[1]
held = (float(self.target) if self._drain_house is None
else self._drain_house)
self._drain_house = min(hi, max(lo, held))
return self._drain_house - float(self.target) + aim
def _track_reads(self) -> None:
"""SAFETY-103, N>=2: per battery, the range its power can be in now:
its last changed reading and every command on its wire since (a
lagging battery moves from one toward the others, never beyond)."""
if len(self.inverters) < 2:
return
for inv in self.inverters:
if inv.batt is None:
continue
cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono
lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None))
if was is None or since != was:
lo = hi = float(inv.batt)
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
@@ -4914,6 +4962,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-103: what each battery can be doing (its reading may be
# older than the meter).
self._track_reads()
# 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)