SAFETY-97: "gives nothing" is graded against each battery's idle reading
checks / unit (pull_request) Failing after 29m7s
infra-pin / fresh (pull_request) Failing after 2s

B's power reading shows -95 to -118 W at idle (standby draw), so the
|reading| < 100 W refusal band never held a refusing B: never at N=2,
and since SAFETY-94's shared rule not at N=1 either (review F1).

Each unit now learns its idle reading (app/idle.py): only while control
runs, the unit is not refused/stale/tripped and its command has stayed
within 50 W for 120 s; first value a 120 s mean, then a 30 min follow;
samples beyond 250 W dropped, value clamped to 250 W; saved per unit and
restored. The shared refusal evidence, compute()'s N=1 engage and end,
the unit refusal ends, the stale-path refusal and SAFETY-42's "parked"
test are centred on it. Until learned it is 0 W, the rule as it was.

Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s
meters), before never; refusing A unchanged. Healthy B with its offset,
SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
This commit is contained in:
2026-10-04 12:21:24 +02:00
co-authored by Claude Opus 5.5
parent dd5eb6fdc8
commit 6918e154c8
10 changed files with 559 additions and 27 deletions
@@ -63,6 +63,7 @@ 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.
- Refusing B (its -100 W idle reading): see SAFETY-97.
- 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
@@ -87,11 +88,10 @@ the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## 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.
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
- 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
@@ -0,0 +1,84 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`). Closes
SAFETY-94 review finding F1.
## The defect
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
B sat on the band's edge and never latched:
- N=2: never caught, before or after SAFETY-94.
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
## Design
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
new option: a required one is a setup step every client gets wrong, and an
optional one is a second source to reconcile. An override can be added
later if a site needs one.
- **When it learns:** control runs; the unit is not refused (unit or site),
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_MAX_W`)
is dropped (a register held at a giving value); the value is clamped to it.
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped. Without it a battery refusing from the
start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was.
## Where it applies
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| check | change |
|---|---|
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
| N=1 end in `_refusal_update` | centred |
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
| SAFETY-57 release | unchanged: it reads the command only |
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds
- Band: `|reading - idle| < 100 W`, `|idle| <= 250 W`.
- A correct idle reading: a refusing B behaves as a refusing A.
- A wrong one (worst, 250 W off): a refusing battery reading outside the band
is not refused by this rule; a frozen register is still caught by the stale
gate at 35 s of asking. Learning only at a ~0 W command for 2 min keeps a
wrong value unlikely. Once learned, a 10 min transient moves it by 28 %;
the first value (a 2 min mean) is the exposed one.
- An idle reading beyond 250 W is never learned: such a unit keeps 0 W.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
| refusing | meter 1 s | 2 s | 5 s |
|---|---|---|---|
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
24 s; with nothing saved: never.
## Before -> after
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
0 refused. Reversals, lag 10/20 s, idle restored: 0.
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).