SAFETY-73: a battery that refuses, per inverter, with two or more
checks / unit (pull_request) Successful in 10m1s
infra-pin / fresh (pull_request) Successful in 1s

SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R).
A unit that gives < 100 W for 12 cycles in one direction while asked
> 200 W, or trips its stale-reading gate on a ~0 W reading, gets a
P_SAFETY `refused` unit limit closing that direction, so the allocator
hands its share to the others within their own limits. Probe: a +-200 W
pin every 15 min, one unit at a time, only while the site asks 200 W that
way. All units refusing: the site law holds 0 W. Exits reseed the site
target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above
the configured cut-out; SAFETY-42/43 no longer report a refusing unit.
N=1 unchanged.

test_refusal_units.py: cases a/b/c on flat and jitter registers against
the old behaviour, probes, recovery, exits; mutations U1-U13 all red.
TEST-19 no-fault campaign: 0 engagements in 5280 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-28 00:13:26 +02:00
co-authored by Claude Opus 5.5
parent 652097abde
commit 016ed5a5a3
10 changed files with 867 additions and 40 deletions
@@ -0,0 +1,89 @@
# 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 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 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.
## 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.