SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
The stale-reading gate counted its 35 s from the register's last change, so after a rest the first read after a new command tripped it (SAFETY-58; live 2026-10-03, four trips 1 s after the command). It now counts only the time the command asked the register to move. At N=1 the refusal engaged after 12 frozen cycles if the reading was under 100 W at that moment, which a battery reversing through its lag passes at a 1 s meter. The refusal now needs the N>1 evidence at every roster size: under 100 W, one way, for 12 cycles spanning 22 s, restarting when the reading moves 50 W towards the ask. DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -484,6 +484,7 @@ def compute(
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
bound: tuple | None = None,
|
||||
gave_nothing: bool | None = None,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -497,6 +498,10 @@ def compute(
|
||||
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
|
||||
their modes applied (`allocator.loop_bound()`), or None. See
|
||||
MODE_BOUND below. None is the law as it was.
|
||||
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
|
||||
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
|
||||
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
|
||||
out (compute() alone, as before).
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
@@ -817,9 +822,14 @@ def compute(
|
||||
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
# SAFETY-94: and only when the reading has given nothing for the
|
||||
# whole span, not just now. `sat_count` also counts the cycles a
|
||||
# healthy battery spends reversing through its lag (-2000 W towards
|
||||
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w) < REFUSED_BELOW_W):
|
||||
and abs(actual_w) < REFUSED_BELOW_W
|
||||
and gave_nothing is not False):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
|
||||
@@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
SAFETY-94: "unchanged for" counts only the reads at which both command terms
|
||||
held, so a register that rested is not judged before it could answer.
|
||||
DECISIONS/SAFETY-94-lagging-battery.md.
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
@@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
|
||||
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
|
||||
property of this battery's firmware. It is not a property of this code, and the
|
||||
add-on must not depend on it.
|
||||
SAFETY-94: many were the gate judging a rested register on its first read
|
||||
(SAFETY-58). Counted from the ask, the same days give 59 trips.
|
||||
|
||||
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
|
||||
exit, not how long the block lasts":
|
||||
@@ -372,7 +378,9 @@ BOUNDS = {
|
||||
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
|
||||
"standing command below 250 W, because at 0 W the register "
|
||||
"is obliged to nothing and the verdict cannot be tested "
|
||||
"again from inside the state it creates"),
|
||||
"again from inside the state it creates. SAFETY-94: the "
|
||||
"35 s counts only reads at which the command asks the "
|
||||
"register to move"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
@@ -416,11 +424,9 @@ class Freshness:
|
||||
new grading window at that instant. The 1 Hz oscillation the latch exists to
|
||||
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
|
||||
no re-trip can land sooner than 35 s after a release, rather than on the
|
||||
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
|
||||
the site rests for longer than the window before it asks for anything, the
|
||||
window is already old and the re-trip lands on the first read at which the
|
||||
command travels 250 W again. That is survivor 5's shape, it predates this
|
||||
card, and its cost is one `stale_input_s` hold.
|
||||
next read. SAFETY-94: the 35 s counts only while the command asks the
|
||||
register to move, so a site that rests after a release is not re-tripped
|
||||
on its first ask.
|
||||
|
||||
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
|
||||
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
|
||||
@@ -451,6 +457,11 @@ class Freshness:
|
||||
# the change-stamps; `_release()` is where they diverge.
|
||||
self.graded_from = None
|
||||
self.standing_at_grade = None
|
||||
# SAFETY-94. Seconds of this window during which the register was
|
||||
# obliged to move (the command at least `move_w` and `move_w` away
|
||||
# from `standing_at_grade`), and the instant of the last read.
|
||||
self.obliged_acc = 0.0
|
||||
self.last_read = None
|
||||
# The instant the standing command last FELL below `move_w`, or None
|
||||
# while an obligation is standing. The release timer runs off this.
|
||||
self.rested_since = None
|
||||
@@ -499,6 +510,7 @@ class Freshness:
|
||||
# it reads in the order the state machine moves - withdraw, then
|
||||
# re-grade - not because the code depends on it.
|
||||
self._release(standing_w, now_mono)
|
||||
self._oblige(standing_w, now_mono)
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _open_window(self, standing_w, now_mono) -> None:
|
||||
@@ -510,6 +522,35 @@ class Freshness:
|
||||
self.graded_from = float(now_mono)
|
||||
self.standing_at_grade = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# The read that opens a window is never an ask (no travel yet).
|
||||
self.obliged_acc = 0.0
|
||||
|
||||
def _obliged(self, standing_w) -> bool:
|
||||
"""Is the register asked to move: the command is at least `move_w` and
|
||||
has travelled `move_w` since the window opened?"""
|
||||
if standing_w is None or self.standing_at_grade is None:
|
||||
return False
|
||||
standing_w = float(standing_w)
|
||||
return (abs(standing_w) >= self.bound.move_w
|
||||
and abs(standing_w - self.standing_at_grade)
|
||||
>= self.bound.move_w)
|
||||
|
||||
def _oblige(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-94. Count only the time the register was asked to move.
|
||||
|
||||
A register that held still while nothing asked it to move has not
|
||||
failed, so time before the command moved is not evidence. The time
|
||||
since the previous read counts when the obligation stands at this
|
||||
read; a command flapping in and out of the obligation still adds up.
|
||||
"""
|
||||
now_mono = float(now_mono)
|
||||
if self.last_read is not None and self._obliged(standing_w):
|
||||
self.obliged_acc += max(0.0, now_mono - self.last_read)
|
||||
self.last_read = now_mono
|
||||
|
||||
def obliged_s(self) -> float:
|
||||
"""Seconds of the current window the register was obliged to move."""
|
||||
return self.obliged_acc
|
||||
|
||||
def _release(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
|
||||
@@ -543,7 +584,12 @@ class Freshness:
|
||||
if self.stale or self.graded_from is None:
|
||||
return
|
||||
held_s = self.graded_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
|
||||
# command asked the register to move, not the time since it last
|
||||
# changed. A register that rested for minutes was otherwise judged on
|
||||
# the first read after a new command, before it could answer (live,
|
||||
# 2026-10-03: 1 s after the command).
|
||||
if self.obliged_s() < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
|
||||
@@ -321,13 +321,14 @@ class Inverter:
|
||||
# take, 0 none), when that refusal or its last probe started, the
|
||||
# current probe's start, and the cycles of evidence so far and when
|
||||
# they began. Memory only, like SAFETY-52's: stop and start end it.
|
||||
# Used at N>1 only.
|
||||
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
|
||||
self.refused = 0
|
||||
self.refused_at = None
|
||||
self.probe_from = None
|
||||
self.ref_count = 0
|
||||
self.ref_since = None
|
||||
self.ref_sign = 0
|
||||
self.ref_from = None
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
|
||||
@@ -281,6 +281,14 @@ def pack_energy_of(inv):
|
||||
# ordinary load steps. At 2 s and slower this changes nothing.
|
||||
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
|
||||
|
||||
# SAFETY-94: the evidence restarts when the reading has moved this far towards
|
||||
# the ask since the evidence began. A refusing battery reads flat (-4 W live,
|
||||
# 2026-09-06); live readings wander by under 25 W at rest (B's standby, -95 to
|
||||
# -118 W, 2026-10-03). A battery crossing the 100 W band on a first-order lag
|
||||
# gains 50 W in at most tau x ln(151/101) = 0.4 tau, so the evidence cannot
|
||||
# span REFUSED_SPAN_S unless the lag is over 54 s.
|
||||
REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
|
||||
|
||||
# SAFETY-85 review: a battery another owner set is graded against the least it
|
||||
# was told over the same span as above (the plant's measured settle time), so
|
||||
# a healthy battery still settling on a new command is not short.
|
||||
@@ -1360,27 +1368,42 @@ class Controller:
|
||||
self._unit_refusal_end(
|
||||
inv, "the battery takes power the other way")
|
||||
continue
|
||||
if b is None:
|
||||
continue # a gap keeps the count: the gate's trip seeds
|
||||
standing = inv.standing_w()
|
||||
sign = 1 if standing > 0 else -1
|
||||
# One direction throughout: a command that swaps sign every cycle
|
||||
# (a flapping load) reads ~0 through the plant's own lag, and that
|
||||
# is a battery following, not refusing (TEST-19 flapping family).
|
||||
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
|
||||
if sign != inv.ref_sign or not inv.ref_count:
|
||||
inv.ref_count, inv.ref_since = 0, self._now_mono
|
||||
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
||||
else:
|
||||
inv.ref_count = 0
|
||||
inv.ref_sign = sign
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
|
||||
inv.ref_since is None or self._now_mono is None
|
||||
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
|
||||
sign = self._gave_nothing(inv)
|
||||
if sign:
|
||||
self._unit_refusal_start(
|
||||
inv, sign,
|
||||
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
|
||||
f"for {_ctl.SETTLE_CYCLES} cycles")
|
||||
f"asked {inv.standing_w():+.0f} W, the battery gave "
|
||||
f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles")
|
||||
|
||||
def _gave_nothing(self, inv) -> int:
|
||||
"""The refusal evidence, one cycle of it: the sign `inv` has given
|
||||
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
|
||||
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
|
||||
SAFETY-94 adds it to the N=1 engage in compute()."""
|
||||
b = inv.batt
|
||||
if b is None:
|
||||
return 0 # a gap keeps the count: the gate's trip seeds
|
||||
standing = inv.standing_w()
|
||||
sign = 1 if standing > 0 else -1
|
||||
# One direction throughout: a command that swaps sign every cycle
|
||||
# (a flapping load) reads ~0 through the plant's own lag, and that
|
||||
# is a battery following, not refusing (TEST-19 flapping family).
|
||||
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
|
||||
# SAFETY-94: a reading still moving towards the ask is a battery
|
||||
# catching up through its lag, not one refusing: start again.
|
||||
if (sign != inv.ref_sign or not inv.ref_count
|
||||
or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W):
|
||||
inv.ref_count, inv.ref_since, inv.ref_from = (
|
||||
0, self._now_mono, b)
|
||||
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
||||
else:
|
||||
inv.ref_count = 0
|
||||
inv.ref_sign = sign
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
|
||||
inv.ref_since is None or self._now_mono is None
|
||||
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
|
||||
return sign
|
||||
return 0
|
||||
|
||||
def _site_refused(self):
|
||||
"""Owner decision 4: every unit refuses the same way, so the site law
|
||||
@@ -3162,8 +3185,9 @@ class Controller:
|
||||
inv.batt_fresh.bound.move_w,
|
||||
messages.reason_code(inv.batt_fresh.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
|
||||
_LOG.debug("%s: the limits are unchanged over %.0f s, a command "
|
||||
"over %.0f W and a move by over %.0f W.", inv.name,
|
||||
_LOG.debug("%s: the limits are unchanged for %.0f s while asked "
|
||||
"to move: a command over %.0f W that moved over %.0f W.",
|
||||
inv.name,
|
||||
inv.batt_fresh.bound.unchanged_s,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.move_w)
|
||||
@@ -4660,6 +4684,10 @@ class Controller:
|
||||
refused=self.batt_refused if len(self.inverters) == 1
|
||||
else self._site_refused(),
|
||||
probe=probe,
|
||||
# SAFETY-94: at N=1 the engage also needs the reading to have
|
||||
# given nothing for the whole span, as at N>1.
|
||||
gave_nothing=(bool(self._gave_nothing(self.inverters[0]))
|
||||
if len(self.inverters) == 1 else None),
|
||||
# OPT-11: None unless battery modes are on. SAFETY-85: or while
|
||||
# maintenance holds a battery at N>=2, so the loop does not
|
||||
# wind past what the others can give and jump when it ends.
|
||||
|
||||
Reference in New Issue
Block a user