SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Failing after 2s

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:
2026-10-04 00:15:42 +02:00
co-authored by Claude Opus 5.5
parent 02ed1200dc
commit 6759d21ea5
11 changed files with 922 additions and 93 deletions
@@ -0,0 +1,94 @@
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
## The defects
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer. Live
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
1 s after its command; each register answered within 11 s. This is
SAFETY-58's shape.
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
frozen cycles if the reading was under 100 W at that moment. The frozen
cycles include a reversal through the battery's lag. At a 1 s meter the
12th cycle comes as a battery reversing out of a charge passes 0 W
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
15-minute probe. N>1 already required 22 s in the band, so it was safe
at a 10 s lag but not at 20 s.
## What changed
- **The gate counts only time the register was asked to move**
(`freshness.Freshness._oblige`). A read counts the time since the last
read when, at that read, the command is 250 W or more and has moved 250 W
from where it stood at the register's last change. A register change
(or a SAFETY-57 release) restarts the count. A command flapping in and
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
- **The bound is unchanged at 35 s and now means what its derivation
says.** The floor was always "how long a healthy register takes to answer
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
6 and 11 s after the command.
- **Refusal evidence is the same at every roster size**
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
The reading must stay under 100 W, one way, while asked over 200 W, for 12
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
needs it on top of its own freeze test. `None` (compute alone) keeps the
old law.
- **The evidence restarts when the reading moves 50 W towards the ask**
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
readings wander by under 25 W at rest. A first-order lag crossing the band
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
This also covers B, whose reading is 100 W below its power.
## Bounds and worst-case catch
| fault | before | after | budget |
|---|---|---|---|
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Before -> after
- Live replay (part 1): 4 trips -> 0.
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
refused, N=2 6 / 1; after 0 / 0.
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
a register held 351 s under a command hovering at the 250 W travel edge
(under 35 s of asking before it moved; survivor 2's shape), and a
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
refusal covers it).
## SAFETY-58 and SAFETY-74
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
start with the obligation and for the bound to be re-derived. Both are
done above. `test_freshness.py` survivor 5 is now graded as closed.
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
command that moved is exactly what the gate is for; telling "limited"
from "frozen" needs a second signal (the meter, or SoC).
## Known gaps
- B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band some of the time, so a refusing B may not latch. Pre-existing,
not touched here.
- A register frozen under a command that stays near the 250 W travel edge
is caught late or not at all (survivor 2).
+6 -3
View File
@@ -590,7 +590,7 @@ accumulator is doing:
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again | | `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance | | `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run | | `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | | `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again | | `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
**When the battery refuses.** At its cut-out level the battery gives nothing, **When the battery refuses.** At its cut-out level the battery gives nothing,
@@ -600,7 +600,9 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
the battery gives 100 W or more, when the grid needs charging instead, and when the battery gives 100 W or more, when the grid needs charging instead, and when
control is stopped or started. 200 W is below the 250 W the stale-reading check control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W, needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request. is not refusing and keeps its request. Nor is a battery whose reading is
still moving towards the request, such as one turning from charging to
giving (SAFETY-94).
**With two or more inverters** the same rule works per inverter. An inverter **With two or more inverters** the same rule works per inverter. An inverter
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
@@ -3457,7 +3459,8 @@ reason code, and its full figures are on the DEBUG line right after it and in
…`, and the peak guard's full reason is on the DEBUG line after its ERROR. …`, and the peak guard's full reason is on the DEBUG line after its ERROR.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The - ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line says when it clears and names `STALE_READING`. The full reason, ERROR line says when it clears and names `STALE_READING`. The full reason,
with both possible causes, is on the DEBUG line after it. with both possible causes, is on the DEBUG line after it. The 35 s counts
only while the command asks the reading to move (SAFETY-94).
- **Meter: no reading for …** (red): the add-on logs one WARNING when the - **Meter: no reading for …** (red): the add-on logs one WARNING when the
meter goes quiet, "No meter reading for …", and an INFO line when readings meter goes quiet, "No meter reading for …", and an INFO line when readings
return, at most once every 10 minutes. Past `stale_input_s` the **Why** return, at most once every 10 minutes. Past `stale_input_s` the **Why**
+11 -1
View File
@@ -484,6 +484,7 @@ def compute(
refused: int | None = None, refused: int | None = None,
probe: bool = False, probe: bool = False,
bound: tuple | None = None, bound: tuple | None = None,
gave_nothing: bool | None = None,
) -> Decision: ) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1). """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 `bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was. 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" reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no # 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 # `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. # limit because it only removes an ask the battery is not meeting.
if refused is not None: 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 if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w 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 refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0: if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way refused = 0 # the loop wants the other way
+53 -7
View File
@@ -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 AND |standing command now - standing command when the reading
last changed| >= MOVE_W 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 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), 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 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 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 property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it. 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 THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts": exit, not how long the block lasts":
@@ -372,7 +378,9 @@ BOUNDS = {
"SAFETY-57: the verdict is withdrawn after 17.0 s of the " "SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register " "standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested " "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( "soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)", signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"), unchanged_s=float("inf"),
@@ -416,11 +424,9 @@ class Freshness:
new grading window at that instant. The 1 Hz oscillation the latch exists to 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 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 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 next read. SAFETY-94: the 35 s counts only while the command asks the
the site rests for longer than the window before it asks for anything, the register to move, so a site that rests after a release is not re-tripped
window is already old and the re-trip lands on the first read at which the on its first ask.
command travels 250 W again. That is survivor 5's shape, it predates this
card, and its cost is one `stale_input_s` hold.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY. ⚠️ 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 `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. # the change-stamps; `_release()` is where they diverge.
self.graded_from = None self.graded_from = None
self.standing_at_grade = 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 # The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this. # while an obligation is standing. The release timer runs off this.
self.rested_since = None self.rested_since = None
@@ -499,6 +510,7 @@ class Freshness:
# it reads in the order the state machine moves - withdraw, then # it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it. # re-grade - not because the code depends on it.
self._release(standing_w, now_mono) self._release(standing_w, now_mono)
self._oblige(standing_w, now_mono)
self._grade(standing_w, now_mono) self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None: def _open_window(self, standing_w, now_mono) -> None:
@@ -510,6 +522,35 @@ class Freshness:
self.graded_from = float(now_mono) self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None self.standing_at_grade = (None if standing_w is None
else float(standing_w)) 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: def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing. """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: if self.stale or self.graded_from is None:
return return
held_s = self.graded_s(now_mono) 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 return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN # ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()` # OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
+2 -1
View File
@@ -321,13 +321,14 @@ class Inverter:
# take, 0 none), when that refusal or its last probe started, the # 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 # 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. # 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 = 0
self.refused_at = None self.refused_at = None
self.probe_from = None self.probe_from = None
self.ref_count = 0 self.ref_count = 0
self.ref_since = None self.ref_since = None
self.ref_sign = 0 self.ref_sign = 0
self.ref_from = None
# -- store --------------------------------------------------------------- # -- store ---------------------------------------------------------------
def key(self, name: str) -> str: def key(self, name: str) -> str:
+49 -21
View File
@@ -281,6 +281,14 @@ def pack_energy_of(inv):
# ordinary load steps. At 2 s and slower this changes nothing. # ordinary load steps. At 2 s and slower this changes nothing.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S 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 # 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 # 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. # a healthy battery still settling on a new command is not short.
@@ -1360,27 +1368,42 @@ class Controller:
self._unit_refusal_end( self._unit_refusal_end(
inv, "the battery takes power the other way") inv, "the battery takes power the other way")
continue continue
if b is None: sign = self._gave_nothing(inv)
continue # a gap keeps the count: the gate's trip seeds if sign:
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):
self._unit_refusal_start( self._unit_refusal_start(
inv, sign, inv, sign,
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W " f"asked {inv.standing_w():+.0f} W, the battery gave "
f"for {_ctl.SETTLE_CYCLES} cycles") 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): def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law """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, inv.batt_fresh.bound.move_w,
messages.reason_code(inv.batt_fresh.reason)) messages.reason_code(inv.batt_fresh.reason))
_LOG.debug("Evidence for %s: %s.", inv.name, 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 " _LOG.debug("%s: the limits are unchanged for %.0f s while asked "
"over %.0f W and a move by over %.0f W.", inv.name, "to move: a command over %.0f W that moved over %.0f W.",
inv.name,
inv.batt_fresh.bound.unchanged_s, inv.batt_fresh.bound.unchanged_s,
inv.batt_fresh.bound.move_w, inv.batt_fresh.bound.move_w,
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 refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(), else self._site_refused(),
probe=probe, 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 # OPT-11: None unless battery modes are on. SAFETY-85: or while
# maintenance holds a battery at N>=2, so the loop does not # maintenance holds a battery at N>=2, so the loop does not
# wind past what the others can give and jump when it ends. # wind past what the others can give and jump when it ends.
+7 -4
View File
@@ -439,7 +439,7 @@ class _Hass:
return True return True
def wired(grid, cycles=40, batt=0.0, track=True, **over): def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
async def go(): async def go():
hass = _Hass(grid, batt=batt, track=track) hass = _Hass(grid, batt=batt, track=track)
c = Controller(dict( c = Controller(dict(
@@ -455,8 +455,11 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
# hysteresis clears it a few cycles later - so it is captured live # hysteresis clears it a few cycles later - so it is captured live
# rather than looked for after the run, when it is correctly gone. # rather than looked for after the run, when it is correctly gone.
c.red_snapshot = [] c.red_snapshot = []
for _ in range(cycles): for i in range(cycles):
await c.read_inputs() # `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs
# its evidence to span REFUSED_SPAN_S, which back-to-back cycles
# on the real clock never do.
await c.read_inputs(now_mono=None if tick is None else i * tick)
await c.cycle() await c.cycle()
if c.clamp_wd.active and not c.red_snapshot: if c.clamp_wd.active and not c.red_snapshot:
c.red_snapshot = c.checks() c.red_snapshot = c.checks()
@@ -474,7 +477,7 @@ def clamp_notes(hass):
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the # unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it # 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
# reports 0 W however hard it is commanded, so the error never closes. # reports 0 W however hard it is commanded, so the error never closes.
hass_r, ctl_r = wired(6000.0, cycles=60, track=False) hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0)
emitted = [abs(w) for w in hass_r.writes] emitted = [abs(w) for w in hass_r.writes]
# What the SAME error would have done to an accumulator with no bound - the # What the SAME error would have done to an accumulator with no bound - the
+85 -54
View File
@@ -900,34 +900,51 @@ def _loop_series(day, sig):
return out return out
class _Before94(Freshness):
"""The gate before SAFETY-94: the clock ran from the register's last
change, not from when the command asked it to move. The history below is
about that gate, so it is replayed on it."""
def obliged_s(self):
return self.graded_s(self.last_read)
def _replay16(cls):
"""Every committed day at 1 Hz. Returns (trips, waits for the next move)."""
waits, trips = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b)
if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = cls(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
trips += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
return trips, sorted(waits)
_t8b = time.monotonic() _t8b = time.monotonic()
_waits, _trips8b = [], 0 _trips8b, _waits = _replay16(_Before94)
for _day in _loop_days: _trips94, _waits94 = _replay16(Freshness)
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
_trips8b += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
_waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
_waits.sort()
_p50 = nth(_waits, len(_waits) // 2) _p50 = nth(_waits, len(_waits) // 2)
print(f" {len(_loop_days)} days replayed at 1 Hz in " print(f" {len(_loop_days)} days replayed at 1 Hz in "
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips") f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
f"SAFETY-94, {_trips94} after")
print(f" wait for the register's next change after a trip: " print(f" wait for the register's next change after a trip: "
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; " f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's " f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
@@ -953,6 +970,17 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE
"is `stale_input_s + release_s` = 32 s whatever the register does", "is `stale_input_s + release_s` = 32 s whatever the register does",
sum(1 for w in _waits if w < 20.0) >= 20 sum(1 for w in _waits if w < 20.0) >= 20
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0) and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}"
f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest "
f"answer after a trip {nth(_waits94, 0):.1f} s")
check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, "
"NOT KEEP-ALIVE PULSES. The clock now counts only the time the command "
"asked the register to move. The same sixteen days give 59 trips, not "
"93, and only one of the 59 is answered within 20 s (21 before): the "
"gate no longer judges a register on the first read after a rest",
_trips94 == 59 and len(_waits94) == _trips94
and sum(1 for w in _waits94 if w < 20.0) == 1
and sum(1 for w in _waits if w < 20.0) == 21)
# ============================================================================== # ==============================================================================
@@ -1017,32 +1045,30 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
"each unit's own command travel and each unit's own number", "each unit's own command travel and each unit's own number",
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held) _s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card. # S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58).
# The unchanged clock keeps running while the site rests, so the FIRST read # The unchanged clock used to keep running while the site rested, so the
# at which a command travels 250 W after a long quiet already satisfies all # FIRST read at which a command travelled 250 W after a long quiet tripped
# three terms - the register is given no time at all to answer the new # the gate. The clock now counts only time the register was asked to move.
# command. Present on the shipped tree; the live capture in part 10 contains # Live 2026-10-03: four such trips, each 1 s after the command.
# one (10:48:14, cleared one second later).
_s5 = Freshness(BOUNDS["batt"]) _s5 = Freshness(BOUNDS["batt"])
for t in range(0, 300): # five minutes of a resting site for t in range(0, 300): # five minutes of a resting site
_s5.submit(-3.0, 0.0, float(t)) _s5.submit(-3.0, 0.0, float(t))
_s5_quiet = _s5.stale _s5_quiet = _s5.stale
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W _s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a " _s5_first = _s5.stale
"long rest the unchanged clock is already past 35 s, so the very FIRST " for t in range(301, 334):
"read carrying a travelled command trips the gate - before the 17.0 s in " _s5.submit(-3.0, 1500.0, float(t))
"which a healthy register could have answered it. The gate accuses on " _s5_before = _s5.stale
"read one and the register answers on read eleven", _s5.submit(-3.0, 1500.0, 334.0)
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0) check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the "
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded " "first read carrying a travelled command is NOT a trip, nor is any read "
"here rather than fixed here: `cycle()` returns at `inputs missing " "before 35 s of standing command - and a register that still has not "
"{n}s` for the whole of it, so nothing is commanded differently, and the " "moved after 35 s of it IS",
"register's next publish clears it. Fixing it means measuring the " not _s5_quiet and not _s5_first and not _s5_before and _s5.stale
"unchanged clock from when the OBLIGATION appeared rather than from the " and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0)
"register's last move, which re-derives the 35.0 s bound and is a " check("...and the evidence sentence still gives the register's own unchanged "
"different card", "time (334 s), which an operator compares with a history graph",
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5) "unchanged for 334 s" in _s5.reason)
and float(option_default("stale_input_s")) >= 5.0)
# ============================================================================== # ==============================================================================
@@ -1353,20 +1379,23 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads "
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register " check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
"that is dead for good is re-graded for ever: over 1201 reads the held " "that is dead for good is re-graded for ever: over 1201 reads the held "
"number reaches a consumer on 630 of them against 35 before, and 17 " "number reaches a consumer on 631 of them against 36 before, and 17 "
"releases are taken. This check exists so that cost cannot be quietly " "releases are taken. This check exists so that cost cannot be quietly "
"forgotten, and so a change that makes it worse has to move a number " "forgotten, and so a change that makes it worse has to move a number "
"somebody reads", "somebody reads",
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17 sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17
and _deadb.releases == 0) and _deadb.releases == 0)
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER " check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s " "CONSUMER'S HOLD. Every unbroken run of exposure after a release is "
"grading window, and 35 < 75 (circulation_hold_s) < 85 " "exactly one 35.0 s window of standing command (the first is 36 s: the "
"fixture's command starts one read late), and 36 < 75 "
"(circulation_hold_s) < 85 "
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside " "(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
"one, and the re-trip that ends each run hands them a None, which CLEARS " "one, and the re-trip that ends each run hands them a None, which CLEARS "
"their windows rather than accumulating into them", "their windows rather than accumulating into them",
set(_runs57) == {BOUNDS["batt"].unchanged_s} set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s}
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold) and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
and max(_runs57) < _circ_hold < _unit_hold)
check("...and nothing SAFETY-37 detects is given back by that, which is the " check("...and nothing SAFETY-37 detects is given back by that, which is the "
"half that makes the trade a trade. A permanent None clears both windows " "half that makes the trade a trade. A permanent None clears both windows "
"for ever too, so on the shipped-before module the same two detectors are " "for ever too, so on the shipped-before module the same two detectors are "
@@ -1643,8 +1672,10 @@ check("...and a re-trip's reason names the TWO durations separately, each "
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)], [f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)], [f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
f.stale and f.releases == 1 f.stale and f.releases == 1
and "unchanged for 92 s" in f.reason # SAFETY-94: the re-trip comes 35 s after the command returns at
and "checked 2 times over a 35 s window since the last stop was " # 80 s (114 s), not 35 s after the release at 57 s.
and "unchanged for 114 s" in f.reason
and "checked 2 times over a 57 s window since the last stop was "
"withdrawn" in f.reason)[-1])( "withdrawn" in f.reason)[-1])(
Freshness(BOUNDS["batt"]))) Freshness(BOUNDS["batt"])))
+8 -2
View File
@@ -153,10 +153,16 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
"is the arbiter on 6000 claim lists", "is the arbiter on 6000 claim lists",
{k: v for k, v in _now.items() if k not in _S91} {k: v for k, v in _now.items() if k not in _S91}
== {k: v for k, v in A5DDDAD.items() if k not in _S91}) == {k: v for k, v in A5DDDAD.items() if k not in _S91})
# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the
# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than
# after its reading last changed - so that run is no longer a5dddad's. It was
# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged.
SAFETY94_N2_S87 = \
"4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5"
check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives " check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
"last), and SAFETY-87's held rule gives a5dddad's back", "last), and SAFETY-87's held rule gives SAFETY-94's",
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"] _now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
and _n2_s87 == A5DDDAD["n2"]) and _n2_s87 == SAFETY94_N2_S87)
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad " check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by " "refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
"SAFETY-85", "SAFETY-85",
+341
View File
@@ -0,0 +1,341 @@
"""Runnable check for SAFETY-94: a healthy battery that lags or reverses is
not flagged as frozen or refusing. `python3 test_safety94.py`
Two false alarms, both pre-existing:
- The stale-reading gate (SAFETY-37) counted its 35 s from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer (SAFETY-58;
live 2026-10-03, four trips, each 1 s after a command).
- At N=1 the refusal (SAFETY-52) engaged after 12 frozen cycles if the reading
was under 100 W at that moment. At a 1 s meter, a battery reversing out of a
charge through its lag is passing 0 W just then (SAFETY-85 rig, O1). It was
held at 0 W until the next probe, up to 15 min.
Now the gate counts only the time the command asked the register to move, and
a refusal needs the reading to have stayed under 100 W, not moving towards the
ask, for 12 cycles spanning 22 s, at every roster size.
DECISIONS/SAFETY-94-lagging-battery.md.
1. The four live trips, replayed through the gate.
2. The REAL Controller on a lagging battery (tools/safety94_rig.py), N=1 and
N=2: rest then a step, maintenance drain start, drain -> charge, a charge
reversing into a discharge. No stale trip and no refusal.
3. A frozen register and a refusing battery are still caught, and how fast.
4. The rules at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.inverter as app_inverter # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.freshness import BOUNDS, Freshness # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class Before94(Freshness):
"""The gate before SAFETY-94: the clock ran from the last change."""
def obliged_s(self):
return self.graded_s(self.last_read)
def without_fix(fn):
"""Run `fn` with the gate's old clock and the old refusal evidence."""
real_f, real_c = app_inverter.Freshness, app_main.compute
real_p = app_main.REFUSED_PROGRESS_W
app_inverter.Freshness = Before94
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
app_main.REFUSED_PROGRESS_W = float("inf")
try:
return fn()
finally:
app_inverter.Freshness, app_main.compute = real_f, real_c
app_main.REFUSED_PROGRESS_W = real_p
# ============================================================================ #
print("1. the four live trips of 2026-10-03, replayed")
# HA history (only changes are recorded), local time: the unit's setpoint
# (what stands on its wire) and its battery power register (0x0518).
LIVE = {
"17:39:16 inverter-b": (
[("17:38:00", -24), ("17:38:14.7", 0), ("17:38:25.4", 5),
("17:38:40.5", 16), ("17:38:44.7", 26), ("17:38:55.7", 37),
("17:39:05.1", 42), ("17:39:09.8", 58), ("17:39:15.6", 268),
("17:39:25.0", 479), ("17:39:30.7", 689), ("17:39:34.8", 900)],
[("17:38:00", -118), ("17:38:21.6", -99), ("17:39:21.6", 179),
("17:39:31.6", 399)]),
"17:39:25 inverter-a": (
[("17:38:00", -16), ("17:38:14.9", 0), ("17:38:25.0", 5),
("17:38:40.0", 14), ("17:38:44.7", 24), ("17:38:55.2", 33),
("17:39:04.7", 38), ("17:39:09.3", 52), ("17:39:15.0", 242),
("17:39:24.6", 431), ("17:39:30.3", 621), ("17:39:34.8", 810)],
[("17:38:00", -3), ("17:38:25.9", 0), ("17:39:25.9", 318),
("17:39:36.0", 654)]),
"19:00:04 inverter-a": (
[("18:58:30.0", 290), ("18:58:43.3", 305), ("18:58:48.0", 314),
("18:58:52.5", 300), ("19:00:03.1", 2500)],
[("18:58:30.0", 292), ("19:00:06.0", 951), ("19:00:16.0", 2360),
("19:00:26.0", 2376)]),
"21:21:22 inverter-b": (
[("21:19:30.0", 0), ("21:21:21.9", 2100), ("21:21:26.6", 1700),
("21:21:31.1", 1300), ("21:21:36.9", 900), ("21:21:41.0", 500),
("21:21:47.3", 200)],
[("21:19:30.0", -98), ("21:21:22.8", -97), ("21:21:32.8", 1411),
("21:21:42.8", 798)]),
}
def _sec(hms):
h, m, s = hms.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
def replay(cls, setp, reg):
"""1 Hz reads, as `run_control()` does. Returns the trip times."""
f, trips = cls(BOUNDS["batt"]), []
t, end = _sec(setp[0][0]), _sec(reg[-1][0])
def at(series, t):
return [float(v) for ts, v in series if _sec(ts) <= t][-1]
while t <= end:
was = f.stale
f.submit(at(reg, t), at(setp, t), t)
if f.stale and not was:
trips.append(t)
t += 1.0
return trips
for name, (setp, reg) in LIVE.items():
old, new = replay(Before94, setp, reg), replay(Freshness, setp, reg)
print(f" {name}: before {[f'{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:04.1f}' for t in old]}"
f", now {len(new)} trips")
check(f"{name}: the shipped trip reproduces 1-2 s after the command, and "
f"now there is none (the register answered within 11 s)",
len(old) == 1 and abs(old[0] - _sec(name.split()[0])) < 1.0
and new == [])
# ============================================================================ #
print("\n2. the REAL Controller on a lagging battery (10 s lag)")
# Two register shapes: published every 10 s and repeating its value (the
# boards, live), and every 1 s with +-1 W of dither (the SAFETY-85 rig).
SHAPES = ((10.0, False), (1.0, True))
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
def sweep(fn=lambda f: f()):
out = {}
for n in (1, 2):
for name in NAMES:
for tick in (1.0, 2.0, 5.0):
for pub, dith in SHAPES:
s, r, _ = fn(lambda: rig.counts(n, name, tick=tick,
publish_s=pub,
dither=dith))
out[(n, name, tick, pub)] = (s, r)
return out
NOW = sweep()
OLD = sweep(without_fix)
for n in (1, 2):
for name in NAMES:
now = [NOW[k] for k in NOW if k[:2] == (n, name)]
old = [OLD[k] for k in OLD if k[:2] == (n, name)]
print(f" N={n} {name:12s} stale/refused now "
f"{sum(a for a, _ in now)}/{sum(b for _, b in now)}, before "
f"{sum(a for a, _ in old)}/{sum(b for _, b in old)}")
check("⭐ no stale trip and no refusal, at N=1 and N=2, at 1, 2 and 5 s meters, "
"on both register shapes: a step after a rest, a drain start, drain -> "
"charge and a charge reversing into a discharge",
all(v == (0, 0) for v in NOW.values()) and len(NOW) == 48)
check("non-vacuous: before, the same runs trip the gate (drain start and "
"drain -> charge at both N, as live) and refuse at N=1 on a 1 s meter",
sum(OLD[(n, nm, t, 10.0)][0] for n in (1, 2)
for nm in ("drain_start", "drain_charge") for t in (1.0, 2.0, 5.0))
>= 12 and OLD[(1, "drain_charge", 1.0, 1.0)][1] >= 1)
def reversals(n, tick, taus=(10.0, 20.0)):
"""A charge reversing into a discharge: (stale trips, refusals) summed."""
s = r = 0
for tau in taus:
for charge in (-1500.0, -2500.0):
for house in (300.0, 800.0, 2000.0):
for pub, dith in SHAPES:
x = rig.run(n, rig.reversal(300.0, charge, house), 600.0,
tau=tau, tick=tick, publish_s=pub,
dither=dith)
s, r = s + len(x["stale"]), r + len(x["refused"])
return s, r
REV = {(n, t): reversals(n, t) for n in (1, 2) for t in (1.0, 2.0)}
REV_OLD = {(n, 1.0): without_fix(lambda n=n: reversals(n, 1.0)) for n in (1, 2)}
print(f" reversals (lag 10 and 20 s): now {REV}, before {REV_OLD}")
check("⭐ SAFETY-94's case: a battery reversing out of a charge (1500 or 2500 "
"W) into a 300-2000 W house, lag 10 or 20 s, 1 or 2 s meter, is never "
"refused or flagged, at N=1 or N=2",
all(v == (0, 0) for v in REV.values()))
check("non-vacuous: before, the 1 s meter refuses at N=1 (lag 20 s) and at "
"N=2 (B, lag 20 s, its reading 100 W below its power)",
REV_OLD[(1, 1.0)][1] >= 1 and REV_OLD[(2, 1.0)][1] >= 1)
# ============================================================================ #
print("\n3. a frozen register and a refusing battery are still caught")
def obliged_at(rows, t_f):
"""First cycle from `t_f` where A's command is 250 W or more and has moved
250 W from where it stood at `t_f`."""
c0 = None
for row in rows:
if row[0] >= t_f and c0 is None:
c0 = row[2]["a"]
if c0 is not None and abs(row[2]["a"]) >= 250 \
and abs(row[2]["a"] - c0) >= 250:
return row[0]
return None
CATCH = {}
for n in (1, 2):
for tick in (1.0, 5.0):
# frozen while giving 1000 W, then the house steps
r1 = rig.run(n, lambda t: 3000.0 if t >= 400 else 1000.0, 520.0,
tick=tick, publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "a" else None)
ob = obliged_at(r1["rows"], 300.0)
st = [t for t, u in r1["stale"] if u == "inverter-a"]
# frozen at rest (HW-01's shape), the house steps 500 s later
r2 = rig.run(n, lambda t: 2500.0 if t >= 600 else 0.0, 700.0,
tick=tick, publish_s=1.0, dither=True,
freeze_at=lambda u: 100.0 if u == "a" else None)
ob2 = obliged_at(r2["rows"], 100.0)
park = min([t for t, u in r2["stale"] if u == "inverter-a"]
+ [t for t, _u in r2["refused"]] or [None])
# a refusing battery from 300 s
r3 = rig.run(n, lambda t: 3000.0, 420.0, tick=tick, publish_s=10.0,
cut=lambda u, t: u == "a" and t >= 300.0)
ref = [t for t, _u in r3["refused"]]
CATCH[(n, tick)] = (st[0] - ob if st and ob is not None else None,
park - ob2 if park is not None and ob2 is not None
else None, ref[0] - 300.0 if ref else None)
print(" (N, meter): (frozen giving: trip after the command moved, frozen "
"at rest: 0 W after, refusing: refused after the cut), in s")
print(f" {CATCH}")
check("⭐ a register frozen while giving is caught 35 s after the command asks "
"it to move (the SAFETY-37 bound, now counted from the ask)",
all(v[0] == BOUNDS["batt"].unchanged_s for v in CATCH.values()))
check("⭐ a register frozen at rest reads like a refusing battery: the "
"battery is at 0 W, refused or stale, within 36 s of the ask",
all(v[1] is not None and v[1] <= 36.0 for v in CATCH.values()))
check("⭐ a refusing battery is still refused: within 22 s plus one 10 s "
"publish at a 1 s meter, and within 12 cycles plus a publish at 5 s",
all(CATCH[(n, 1.0)][2] is not None and CATCH[(n, 1.0)][2] <= 32.0
for n in (1, 2))
and all(CATCH[(n, 5.0)][2] is not None
and CATCH[(n, 5.0)][2] <= 12 * 5.0 + 10.0 for n in (1, 2)))
# ============================================================================ #
print("\n4. the rules at their edges")
# The gate counts time the register was asked to move, across a flapping
# command too.
f = Freshness(BOUNDS["batt"])
trip = None
for t in range(0, 200):
f.submit(-3.0, 1500.0 if (t // 5) % 2 else 0.0, float(t))
if f.stale and trip is None:
trip = t
print(f" command flapping 0 / 1500 W every 5 s, register frozen: trip at "
f"{trip} s")
check("a frozen register under a command that flaps in and out of the ask is "
"still caught (after 35 s of asking, about 70 s here)",
trip is not None and 65 <= trip <= 75)
f = Freshness(BOUNDS["batt"])
for t in range(0, 30):
f.submit(-3.0, 1500.0, float(t))
f.submit(400.0, 1500.0, 30.0) # it answers
for t in range(31, 60):
f.submit(400.0, 1500.0, float(t))
check("a register that answers starts a fresh count: 30 s asked, then it "
"moves, then 29 s more is not stale",
not f.stale and f.obliged_s() < 35.0)
f = Freshness(BOUNDS["batt"])
for t in range(0, 600):
f.submit(-3.0, 200.0 if t % 60 < 3 else 0.0, float(t))
check("a command that never asks 250 W is never an ask (SAFETY-52's 200 W "
"probe cannot arm the gate)",
not f.stale and f.obliged_s() == 0.0)
# compute(): `gave_nothing` gates the N=1 engage; None keeps the old law.
T = control.Tuning(max_w=4000.0, slew_w=400.0)
args = dict(prev_w=1200.0, grid_w=1200.0, actual_w=-20.0, tuning=T,
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0)
check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
"gave_nothing None (compute alone, as before) or True, and not with "
"False",
control.compute(**args).refused == 1
and control.compute(**args, gave_nothing=True).refused == 1
and control.compute(**args, gave_nothing=False).refused == 0)
# The evidence restarts on progress towards the ask, not on jitter.
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
f"{app_main.REFUSED_SPAN_S:.0f} s")
class _Inv:
def __init__(self):
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
self.ref_from = None
def standing_w(self):
return 800.0
class _C:
_now_mono = 0.0
def evidence(readings, tick=1.0):
c, inv = _C(), _Inv()
got = []
for i, b in enumerate(readings):
c._now_mono, inv.batt = i * tick, b
got.append(app_main.Controller._gave_nothing(c, inv))
return got
flat = evidence([-4.0 + (i % 3) for i in range(30)])
climb = evidence([-95.0 + 4.0 * i for i in range(48)])
check("a flat reading (+-1 W) under an 800 W ask is evidence after 22 s; one "
"climbing 4 W/s through the band is never",
1 in flat and flat.index(1) == 22 and not any(climb))
check("REFUSED_PROGRESS_W is under the band's width and over the live "
"readings' rest wander (25 W)",
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""SAFETY-94: the REAL Controller on a lagging battery whose reading is late.
python3 tools/safety94_rig.py # prints every scenario's counts
Each battery ramps at 800 W/s, then follows a first-order lag (`tau`, 10 s
as on the SAFETY-85 rig). Its reading (register 0x0518) is published every
10 s, at a different phase per unit, and repeats its value between publishes,
as the live boards do. B reads 100 W below what it gives (its standby draw:
-100 W at idle, live 2026-10-03). The meter updates every `tick` s; the add-on
reads every 1 s and cycles on a new meter value, as `run_control()` does.
A frozen register stops publishing at `freeze_at`; a refusing battery (`cut`)
gives 0 W and reads -4 W. Uses only the Controller API release/1.0 02ed120
has, so the same file runs on the tree before SAFETY-94.
"""
import asyncio
import math
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
BASE = datetime(2026, 10, 3, 10, 50, tzinfo=timezone.utc) # drain from 600 s
PUBLISH_S = 10.0
PHASE = {"a": 6.0, "b": 1.6} # live: A publishes at :x6, B at :x1.6
OFFSET = {"a": 0.0, "b": -100.0} # B's standby draw, live 2026-10-03
CUT_W = -4.0
LIVE = {"gain": 0.6, "slew_w": 400, "target_grid_w": -10, "deadband_w": 15,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"auto_start": True, "meter_entity": "sensor.grid",
"meter_source": "off"}
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
"maintenance_discharge_w": 2500, "maintenance_hold_min": 30}
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Plant:
"""The batteries, their registers and the meter, in simulated seconds."""
def __init__(self, units, load, tau, tick, soc, cut, freeze_at,
publish_s=PUBLISH_S, dither=False):
self.units, self.load, self.tau, self.tick = units, load, tau, tick
self.publish_s, self.dither, self.n_pub = publish_s, dither, 0
self.soc_fn, self.cut, self.freeze_at = soc, cut, freeze_at
self.cmd = {u: 0.0 for u in units}
self.ramp = {u: 0.0 for u in units}
self.real = {u: 0.0 for u in units}
self.reg = {u: round(OFFSET[u]) for u in units}
self.grid_v = float(load(0.0))
self.t = 0.0
def advance(self, t):
"""Integrate to `t` in 0.1 s steps; publish and sample on schedule."""
while self.t < t - 1e-9:
self.t = round(self.t + 0.1, 6)
for u in self.units:
want = 0.0 if self.cut(u, self.t) else self.cmd[u]
d = want - self.ramp[u]
self.ramp[u] += math.copysign(min(abs(d), 80.0), d)
self.real[u] += ((self.ramp[u] - self.real[u])
* (1 - math.exp(-0.1 / self.tau)))
frozen = (self.freeze_at is not None
and self.freeze_at(u) is not None
and self.t >= self.freeze_at(u))
ph = (self.t - PHASE[u] % self.publish_s) / self.publish_s
if not frozen and abs(ph - round(ph)) < 1e-6:
# `dither`: the +-1 W a settled register shows (live).
self.n_pub += 1
jit = (self.n_pub * 7 % 3 - 1) if self.dither else 0
self.reg[u] = (CUT_W if self.cut(u, self.t)
else float(round(self.real[u] + OFFSET[u])
+ jit))
if abs(self.t / self.tick - round(self.t / self.tick)) < 1e-6:
self.grid_v = float(round(self.load(self.t)
- sum(self.real.values())))
class _Hass:
def __init__(self, plant):
self.p = plant
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.p.grid_v
u = e[-1]
if e.startswith("sensor.soc"):
return self.p.soc_fn(u, self.p.t)
if e.startswith("sensor.batt"):
return self.p.reg[u]
return None
async def set_number(self, e, v):
self.p.cmd[e[-1]] = float(v)
return True
def options(n, extra=None):
o = dict(LIVE)
if n == 1:
o.update({"setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 4000, "bms_soc_floor": 10})
else:
o["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]]
o.update(extra or {})
return o
def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
freeze_at=None, extra=None, floor_s=15.0, publish_s=PUBLISH_S,
dither=False):
"""Returns a dict: per-unit stale trips [(t, unit)], refusals
[(t, unit or 'site')], rows [(t, grid, {u: cmd}, {u: real}, {u: reg},
phases)] and the controller."""
units = tuple("ab"[:n])
soc = soc or (lambda u, t: 50.0)
cut = cut or (lambda u, t: False)
plant = Plant(units, load, tau, tick, soc, cut, freeze_at, publish_s,
dither)
async def go():
c = Controller(options(n, extra), _Hass(plant), _Store(), _Pub(),
stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
out = {"stale": [], "refused": [], "rows": [], "c": c}
was = {i.name: False for i in c.inverters}
was_ref = {i.name: 0 for i in c.inverters}
was_site = 0
last_grid, last_cycle = None, None
try:
t = 0.0
while t < dur:
plant.advance(t)
clock[0] = t
await c.read_inputs(now_mono=t)
for i in c.inverters:
if i.batt_fresh.stale and not was[i.name]:
out["stale"].append((t, i.name))
was[i.name] = i.batt_fresh.stale
cycled = (c.grid != last_grid or last_cycle is None
or t - last_cycle >= floor_s)
if cycled:
last_grid, last_cycle = c.grid, t
await c.cycle()
# A refusal can start on a read (a gate trip) or in a cycle.
for i in c.inverters:
if i.refused and not was_ref[i.name]:
out["refused"].append((t, i.name))
was_ref[i.name] = i.refused
if c.batt_refused and not was_site:
out["refused"].append((t, "site"))
was_site = c.batt_refused
if cycled:
out["rows"].append((
t, plant.grid_v, dict(plant.cmd),
{u: round(v) for u, v in plant.real.items()},
dict(plant.reg), [mm.phase for mm in c.maints]))
t += 1.0
finally:
m.datetime = real_dt
return out
return asyncio.run(go())
# --- the scenarios ---------------------------------------------------------
def rest_then(step_at, w):
"""A house at 0 W (the batteries rest) until `step_at`, then `w`."""
return lambda t: w if t >= step_at else 0.0
def reversal(at_s, charge_w, house_w):
"""Solar surplus (the batteries charge), then a house load from `at_s`."""
return lambda t: house_w if t >= at_s else charge_w
def drain_soc(floor_at):
"""A drains (maintenance) and reaches its floor at `floor_at`; B at 50 %."""
def soc(u, t):
if u != "a":
return 50.0
return 50.0 if t < floor_at else 10.0
return soc
SCENARIOS = {
# first command after a long rest (the live 17:39 shape)
"rest_step": dict(load=rest_then(600.0, 2400.0), dur=900.0),
# maintenance drain starts after a rest (the live 19:00 shape)
"drain_start": dict(load=lambda t: 300.0, dur=900.0,
extra=MAINT, soc=drain_soc(1e9)),
# A's drain ends and its charge starts (the live 21:21 shape)
"drain_charge": dict(load=lambda t: 300.0, dur=1200.0, extra=MAINT,
soc=drain_soc(900.0)),
# a charge reverses into a discharge through the lag (SAFETY-94)
"reversal": dict(load=reversal(300.0, -2500.0, 800.0), dur=600.0),
}
def counts(n, name, **kw):
sc = dict(SCENARIOS[name], **kw)
r = run(n, **sc)
return len(r["stale"]), len(r["refused"]), r
if __name__ == "__main__":
for n in (1, 2):
for name in SCENARIOS:
for tick in (1.0, 2.0, 5.0):
for pub, dith in ((10.0, False), (1.0, True)):
s, f, r = counts(n, name, tick=tick, publish_s=pub,
dither=dith)
print(f"N={n} {name:13s} tick {tick:.0f} s publish "
f"{pub:2.0f} s{' dither' if dith else ' '}: "
f"stale {s} {r['stale'][:2]}, refusals {f} "
f"{r['refused'][:2]}")