# SAFETY-52 - a battery that refuses is asked for 0 W **Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four questions answered yes.** ## The defect At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation freeze (`control.py`, `frozen`) then holds the request where it stood, and its SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies inside the interval a limit can produce. The request stood at +5000 W for 11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It moved again only when the grid error changed sign. SAFETY-48's lead cap only changes where `i_w` parks (one slew step ahead instead of at the rail). When the request moved after the reading went flat, the stale-reading gate won instead and ran SAFETY-59's ~70 s trip cycle. ## What was decided 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 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 carried like `sat_count` (`refused=`, `Decision.refused`). 2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`, `PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held 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. 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 cycle. The log keeps SAFETY-56's two-explanation wording. 4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC. Other exits: the loop wants the other direction (charge is accepted at the floor), control stopped or started, another writer on the wire (the arbiter reseed). A gap in the readings keeps the refusal, because the gate's own stop is what seeds it. **One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty battery's discharge end and moves its share. There, a one-unit gate trip must not refuse the site. `compute()` is passed `refused=None` there, and that path is release/1.0's law unchanged (the `i_w=None` equivalence sweep in `test_control.py` still holds). ## Evidence `test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19 shapes, recovery with no sign change, the probe cadence, every exit). `tools/replay_refusal.py` replays the 16-day capture, same model as `replay_recorded_loop.py`, and should be read for deltas: | 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 | 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 | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W | **Traced in review (#141).** The +22 W worst quarter is the old law's worst quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W 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 +541 W worse or cycled the gate 3368 times, and an SoC envelope needs the 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 A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a genuine limit (SAFETY-13 case a) and still holds its request. ## What SAFETY-58 and SAFETY-59 must take into account - **SAFETY-58** (the first read after a rest trips at once): a probe never arms the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays the first verdict, the seed arrives later. The 09-19 shape then needs the compute-side engagement (12 cycles) to win, which it does only while the request stands still. Keep `test_refusal.py` part 4 green. - **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this change a refusing battery no longer produces the pattern at one inverter. The DOCS text should say so, and that the `battery refuses` reason is the discriminator the add-on now has. At N>1 the old pattern can still occur. - If either changes `MOVE_W`, `PROBE_W` must stay below it. `test_refusal.py` part 2 checks the inequality.