Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-52-a-battery-that-refuses.md
T
glenn schrooyenandClaude Opus 5.5 1f97c7652f
checks / unit (pull_request) Failing after 9m32s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:27 +02:00

4.5 KiB

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 within saturation_w of 0 against an ask beyond it. 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). An answer of at least 100 W ends the refusal. 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 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

The +22 W worst quarter and the +45 trips on the fitted plant follow the fit's -22 W offset, which slowly charges the modelled pack above its floor while the hold stands. With the offset at 0 both vanish. That is inferred from these two rows and was not traced further. 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.

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.