# SAFETY-73 - a battery that refuses, with two or more inverters **Decided 2026-09-27 by the owner, on the phase A plan (option R). All four new questions answered yes. SAFETY-52's four decisions carry over.** ## The defect SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1 (`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while its state of charge reads above its configured cut-out, so the allocator kept handing it its weighted half. Measured on the real `Controller`, two units, house 3000 W, A cut out: | register | law | B gives | grid | A's gate trips / h | |---|---|---|---|---| | flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) | | flat -4 W | new | 2000 W (its rating) | +1004 W | 1 | | jitter | old | 1910 W | +1094 W | 0 | | jitter | new | 2000 W | +1004 W | 0 | House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter) standing import, new -16 W. Both cut out: old keeps asking two empty batteries (+1910 W each on the jitter shape), new holds the site at 0 W. ## What was decided 1. **A refusing unit is closed in that direction** by its own P_SAFETY unit claim, source `refused`. It engages when the unit gives under `REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least `REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W` (200 W), or when its stale-reading gate trips on a held reading under 100 W. The allocator then hands its share to the others within their own limits (owner decision 1). It is a hard unit limit that always intersects, so OPT-11's P_MODE layer fits inside it. 2. **It binds every strategy's ask on that unit** (decision 2): peak guard, arbitrage and maintenance are placed around it like the loop. 3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a unit: its share is 0 W, and `account()` treats it as parked. 4. **All units refusing the same way** pass that sign to `compute()`, so the site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a time. 5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the others carry the rest. The pin stands only while the resolved site target asks at least 200 W its way; otherwise it would make a sibling take the opposite direction, so it falls back to the closed limit for that cycle. 6. **Exits:** a reading of 100 W or more in the held direction (probe or not); 100 W or more taken the other way for 12 cycles (charge at the floor; one cycle of the loop overshooting through zero is not enough); stop or start. On every exit the site target and integrator restart from the watts on the wire, or the reopened unit is handed a parked remainder at once. **Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W night load split two ways asks 200 W of each. With 500 W the empty battery kept a phantom share that SAFETY-43's relative arm then reported as not delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give under 100 W are "too small to call" in `account()`: the sibling covers the meter and nothing is asked of the empty battery that matters. **Why one direction throughout.** On TEST-19's flapping family a command that swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21 no-fault runs without the rule, 0 with it. **N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`. `test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every SAFETY-73 method made to raise. ## Evidence - `test_refusal_units.py`: cases a, b and c on both register shapes against the old behaviour, probes and their spacing, one-at-a-time probing, the recovery reseed, each exit, the small-ask and small-site-target cases, the WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring. - **No false engagements.** TEST-19's no-fault campaign (admin/goodwe `sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every unit executing its share) graded with the engage clause: 0 engagements, the longest qualifying streak 5 cycles against 12. The grader is `goodwe-session/scratch_s73/s73_nofault.py`. - **Recovery.** Case a, A recovering into a house that fell below the parked target: worst grid +0 W with the reseed. Phase A measured -1059 W without the reseed and the site hold. ## Review changes - **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12 cycles were 11 s, shorter than a healthy reading's lag (dead time plus a 10-17 s publish). The reviewer's harness (real `Controller`, both units with 6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then refused both healthy units 10-16 times an hour, and each time the site held 0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is `(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged. - **The cut-out WARNING is for a refusal to give power only.** A full battery that stops taking charge is always above its cut-out level, and the WARNING told the installer to check a setting that has nothing to do with it. ## Not covered - A unit that gives a steady part of its ask (100 W or more) is a limit, as at N=1. SAFETY-42/43 still report it. - `pack_permissions` stays SoC-based, so the peak guard may size a shave with a refusing battery counted. The shortfall shows as `unmet_w`. - A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as not delivering. Its share still moves, which is the gain SAFETY-42 measured.