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:
@@ -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).
|
||||||
@@ -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**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()`
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])))
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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]}")
|
||||||
Reference in New Issue
Block a user