# 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//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).