Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md
T
glenn schrooyenandClaude Opus 5.5 dd5eb6fdc8
checks / unit (pull_request) Successful in 28m7s
infra-pin / fresh (pull_request) Failing after 2s
SAFETY-94 review: tests for three surviving mutants; record two gaps
- test_safety94 part 4: time under 250 W is not counted; a command that
  swaps direction every cycle is never refusal evidence; a reading moving
  away from the ask does not restart the evidence. These kill review
  mutants R1, R3 and R6, which survived the PR's tests.
- DECISIONS: at N=1 a battery with B's standby offset no longer latches
  a refusal (SAFETY-97 now covers N=1); a part-time ask is caught after
  35/d s of wall time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:52 +02:00

5.7 KiB

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 (SAFETY-97). At N=2 this is unchanged. At N=1 it is new: the old one-instant check caught such a battery in 17-105 s; the shared rule needs 12 cycles in the band in a row and, on the review probe, never latched in 1200 s.
  • A command that asks only part of the time (a fraction d) is caught after 35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s). SAFETY-30's per-unit check restarts when its command stops opposing the held reading, which a lapsing ask normally does, so 35 < 85 holds there. SAFETY-31's circulation check (75 s) ignores the command, so its margin holds only while d >= 0.47.
  • A register frozen under a command that stays near the 250 W travel edge is caught late or not at all (survivor 2).