SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
checks / unit (pull_request) Successful in 9m22s
infra-pin / fresh (pull_request) Successful in 1s

- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's
  own 100 W answer, not below saturation_w: a battery derated to 100-499 W
  was held at 0 W ~93 % of the time.
- A reading of 100 W or more in the held direction ends the refusal, probe
  or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy
  reading's lag, and that held a healthy step at 0 W until the next probe.
- test_refusal part 5b and a seed test; DOCS states the N>1 limit; the
  DECISIONS record carries the traced cause of the replay's extra trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 18:11:42 +02:00
co-authored by Claude Opus 5.5
parent 1d33265aac
commit 3c0f2b9669
7 changed files with 153 additions and 24 deletions
@@ -19,12 +19,13 @@ instead and ran SAFETY-59's ~70 s trip cycle.
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages 1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
within `saturation_w` of 0 against an ask beyond it. The drop to 0 skips below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
`saturation_w`. The drop to 0 skips
the slew limit (it only removes an ask nobody meets). The direction is the slew limit (it only removes an ask nobody meets). The direction is
carried like `sat_count` (`refused=`, `Decision.refused`). carried like `sat_count` (`refused=`, `Decision.refused`).
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`, 2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
`PROBE_S`; twice `BATT_OBLIGED_S`). An answer of at least 100 W ends the `PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
refusal. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless. the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for 3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
control (`Controller._refusal_from_stale`). This is what stops the ~70 s control (`Controller._refusal_from_stale`). This is what stops the ~70 s
@@ -52,18 +53,43 @@ shapes, recovery with no sign change, the probe cadence, every exit).
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min | | plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W | | fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
| fitted | new | 130 | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W | | fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W | | offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W | | offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
The +22 W worst quarter and the +45 trips on the fitted plant follow the fit's **Traced in review (#141).** The +22 W worst quarter is the old law's worst
-22 W offset, which slowly charges the modelled pack above its floor while the quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
hold stands. With the offset at 0 both vanish. That is inferred from these two new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
rows and was not traced further. Rejected alternatives and their figures are in charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
just above its floor, the probe is answered, control ramps, the pack empties
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
follow an other-direction exit the same way. On a site this is one
`STALE_READING` ERROR line per answered probe at most, and only on a battery
that answers a probe and then cuts out again. Catching the re-cut before the
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
battery, so it is left to SAFETY-58/59. After the review changes below the
fitted-plant row reads 133 trips (the delivery exit now also catches answers
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
the phase A plan: bounding the integrator by demand made the worst quarter the phase A plan: bounding the integrator by demand made the worst quarter
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the +541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
configured floor to be right and needs SoC. configured floor to be right and needs SoC.
## Review changes (#141)
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
old law. The capture has 0.11 h in that band, so the replay does not move.
- **Any delivery ends the hold, not only an answered probe.** With
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
held it for 15 min. Now the late reading ends it within seconds (2 s held in
`test_refusal.py` part 5b).
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
battery only when its cut-out level is configured right.
## Not covered ## Not covered
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
+11 -5
View File
@@ -538,17 +538,23 @@ accumulator is doing:
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise | | `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
| `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 |
| `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 nothing for a minute 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 (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,
whatever it is asked. The add-on then asks 0 W instead of repeating its last whatever it is asked. The add-on then asks 0 W instead of repeating its last
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
15 minutes it asks 200 W for about half a minute; if the battery answers, 15 minutes it asks 200 W for about half a minute. Control resumes as soon as
control resumes. It also resumes 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. One inverter only: with several, the battery needs, so a test never trips it. A battery that gives some power, even 100 W,
level limit already moves an empty battery's share to the others. is not refusing and keeps its request.
⚠️ **One inverter only.** With several inverters this hold is off. There, the
battery level limit moves an empty battery's share to the others, but only if
its “Battery cut-out level” matches where the battery really stops. A battery
that refuses at another level, or for another reason (cold, a battery fault),
can still hold its last request until the grid starts to export.
*For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and *For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and
`BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`. `BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`.
+10 -5
View File
@@ -286,6 +286,10 @@ REFUSED = "battery refuses"
PROBING = "battery probe" PROBING = "battery probe"
PROBE_W = 200.0 PROBE_W = 200.0
PROBE_ANSWER_W = PROBE_W / 2 PROBE_ANSWER_W = PROBE_W / 2
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
# (review of #141). The capture's refusals read -4 W.
REFUSED_BELOW_W = PROBE_ANSWER_W
PROBE_EVERY_S = 900.0 PROBE_EVERY_S = 900.0
PROBE_S = 34.0 PROBE_S = 34.0
@@ -773,18 +777,19 @@ def compute(
# --- SAFETY-52: a refused direction is held at 0 W ---------------------- # --- SAFETY-52: a refused direction is held at 0 W ----------------------
# Engages only where the freeze has held for SETTLE_CYCLES and the unit # Engages only where the freeze has held for SETTLE_CYCLES and the unit
# delivers ~nothing: a reading within `saturation_w` of 0 against an ask # delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
# beyond it 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:
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) < tuning.saturation_w): and abs(actual_w) < REFUSED_BELOW_W):
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
elif refused and probe and refused * actual_w >= PROBE_ANSWER_W: elif refused and refused * actual_w >= PROBE_ANSWER_W:
refused = 0 # the probe was answered refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused: elif refused:
wants = refused * i_w > 0 wants = refused * i_w > 0
i_w = 0.0 i_w = 0.0
+4 -3
View File
@@ -1108,8 +1108,9 @@ class Controller:
f"gave {batt_w:+.0f} W for " f"gave {batt_w:+.0f} W for "
f"{decision.sat_count} cycles") f"{decision.sat_count} cycles")
elif self.batt_refused and not now: elif self.batt_refused and not now:
answered = probe and self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
self._refusal_end("the probe was answered" if answered self._refusal_end("the probe was answered" if gave and probe
else "the battery gives power" if gave
else "the grid now asks the other direction") else "the grid now asks the other direction")
def _refusal_from_stale(self, inv, now_mono) -> None: def _refusal_from_stale(self, inv, now_mono) -> None:
@@ -1124,7 +1125,7 @@ class Controller:
and not self.batt_refused and not self.batt_refused
and self.sat_count >= t.saturation_cycles and self.sat_count >= t.saturation_cycles
and inv.batt_fresh.held is not None and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held) < t.saturation_w and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(standing) > t.saturation_w): and abs(standing) > t.saturation_w):
self._now_mono = now_mono self._now_mono = now_mono
self._refusal_start(1 if standing > 0 else -1, self._refusal_start(1 if standing > 0 else -1,
+4 -2
View File
@@ -1147,8 +1147,10 @@ async def _one():
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt", "soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6, "meter_source": "off", "max_w": 2000, "gain": 0.6,
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT # ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock # MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall
# against a 1 s window, so a hold is not what stops this firing - # clock against a 1 s window (SAFETY-52 shortened it from 2 s, and
# "the window was never even opened" below is what still makes it
# non-vacuous), so a hold is not what stops this firing -
# the reduction is. With a shipped-length hold "nothing was # the reduction is. With a shipped-length hold "nothing was
# reported" would be true of a fixture that simply ran out of time. # reported" would be true of a fixture that simply ran out of time.
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1, "bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
+89
View File
@@ -34,6 +34,12 @@ test_messages (MUTATION.md). Reds are failing checks, on this tree:
R7 main.py: another writer no longer ends the refusal 1 R7 main.py: another writer no longer ends the refusal 1
R8 main.py: stop/start no longer ends the refusal 1 R8 main.py: stop/start no longer ends the refusal 1
R9 main.py: the probe window never opens 4 R9 main.py: the probe window never opens 4
R10 main.py: seed from a stale stop below `saturation_w` again, 1
not below REFUSED_BELOW_W (review of #141). The same mutation
in control.py's engage clause SURVIVES, and that is expected:
the delivery exit (R11) ends such a refusal in the same cycle.
R11 control.py: only an answered PROBE ends the refusal 2
(`and probe` back in the delivery exit; review of #141)
""" """
import asyncio import asyncio
@@ -366,6 +372,89 @@ rows, _, c = run(load906, cut906, 900, cap906, events={600.0: other_writer})
check("another writer on the wire ends the refusal (the arbiter reseed)", check("another writer on the wire ends the refusal (the arbiter reseed)",
at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0) at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0)
# ==============================================================================
print("5b. review of #141: a pack that gives SOME power, and a reading that is "
"only late, are not a refusal")
# ==============================================================================
# A pack derated to 100-499 W (cold, BMS taper) gives what it can against a
# larger ask. Engaging below `saturation_w` (500 W) held it at 0 W for ~93 % of
# the time; "gives nothing" is below the probe's own answer.
# `compute()` alone: through the Controller the stale-reading gate trips on the
# flat 300 W first, and that fixture then passes whatever this threshold is.
def derated(carry, cap_w=300.0, n=1800):
prev, i_w, sat, ref, gave, held = 0.0, 0.0, 0, carry, 0.0, 0
b = 0.0
for _ in range(n): # 2.5 h at 5 s
d = control.compute(prev, 2000.0 - b, b, T, sat, i_w, refused=ref)
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
held += bool(ref)
b = max(0.0, min(prev, cap_w)) # one tick late
gave += b
return gave / n, held
T = control.Tuning(**LIVE)
gave_old, _ = derated(None)
gave_new, held_new = derated(0)
print(f" pack derated to 300 W: old law {gave_old:.0f} W average, new "
f"{gave_new:.0f} W, held as refusing on {held_new} cycles")
check("a pack that gives a steady 300 W against a 2000 W ask is never held at "
"0 W as refusing, and keeps giving what the old law got from it",
held_new == 0 and gave_new == gave_old and gave_old > 290.0)
def lagging(tick, dead_s=6.0, publish_s=10.0, phase_s=5.0, dur=600.0):
"""`compute()` on a HEALTHY pack whose reading lags: it follows the command
of `dead_s` ago, published every `publish_s`. A 1 s meter (`meter_poll_s`
1) makes SETTLE_CYCLES only 12 s, shorter than that lag."""
prev, i_w, sat, ref, hist, reg, pub = 0.0, 0.0, 0, 0, [], 0.0, -phase_s
held = engaged = 0
t = 0.0
while t < dur:
then = [c for tc, c in hist if tc <= t - dead_s]
b = then[-1] if then else 0.0
if t - pub >= publish_s:
reg, pub = b, t
d = control.compute(prev, (3000.0 if t >= 10 else 0.0) - b, reg, T,
sat, i_w, refused=ref)
engaged += bool(d.refused) and not ref
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
hist.append((t, prev))
held += tick * bool(ref)
t += tick
return engaged, held
lag1 = lagging(1.0)
print(f" 1 s meter, healthy pack with a late reading: engaged {lag1[0]} "
f"time(s), held {lag1[1]:.0f} s")
check("a healthy pack whose reading is merely late is let go as soon as the "
"reading shows it delivering - not held until the next probe",
lag1[1] <= 10.0)
async def _seed(held_w):
"""`_refusal_from_stale()` on a unit standing at +2000 W, frozen."""
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off"}, **LIVE),
_Hass(lambda t: 0.0, lambda t: False), _Store(), _Pub(),
stop=asyncio.Event())
c.auto, c.sat_count = True, c.tuning.saturation_cycles
inv = c.inverters[0]
inv.last_write, inv.batt_fresh.held = 2000.0, held_w
c._refusal_from_stale(inv, 100.0)
return c.batt_refused
check("a stale-reading stop on a register standing at 300 W is not a refusal "
"(it gives something), and one at -4 W is",
asyncio.run(_seed(300.0)) == 0 and asyncio.run(_seed(CUT_W)) == 1)
d = control.compute(0.0, 1500.0, 1400.0, T, 0, 0.0, refused=1, probe=False)
check("...i.e. a held direction that reads 100 W or more ends the refusal "
"without waiting for a probe", d.refused == 0)
# ============================================================================== # ==============================================================================
print("6. words") print("6. words")
# ============================================================================== # ==============================================================================
+1 -1
View File
@@ -43,7 +43,7 @@ def go(which, rows, fit, publish_s=10.0, stale_input_s=15.0):
if fr.stale and not was: if fr.stale and not was:
trips += 1 trips += 1
if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles
and abs(reg) < tun.saturation_w and abs(st["target"]) > tun.saturation_w): and abs(reg) < NEW.REFUSED_BELOW_W and abs(st["target"]) > tun.saturation_w):
st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t
was = fr.stale was = fr.stale
if batt is None: if batt is None: