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
+25 -7
View File
@@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone
from named_absence import given, present
from app import messages
from app import control, messages
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
@@ -592,7 +592,21 @@ check("...and the SHARES now differ, which is OPT-09 and not a weakening of the
"cards meet here from opposite sides: OPT-09 stops the ASK, this file "
"grades the ALARM, and neither can do the other's half",
_pw != _pf
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
and _sf[-1]["shares"][1] == 0.0
and max(r["shares"][1] for r in _sw) == 2000.0)
# SAFETY-73. A unit that gives < 100 W for 12 cycles while asked > 500 W is a
# battery that refuses (owner, 2026-09-27): it is closed, its share moves, and
# SAFETY-42 is left with units that deliver something but not enough. This
# fixture compresses `delivery_hold_s` to 1 s at 0.2 s cycles, so the report
# still comes first here. On a site 185 s against 60 s puts the refusal first.
_ref_at = next((i for i, r in enumerate(_sw) if r["shares"][1] == 0.0), None)
check("SAFETY-73: the wedged arm's B, giving 0 W against 2000 W, is refused "
"after 12 cycles of that evidence and asked 0 W from then, the report "
"having come first only because this fixture's hold is 1 s",
_ref_at is not None and _ref_at >= control.SETTLE_CYCLES
and all(r["shares"][1] > 0 for r in _sw[:_ref_at])
and all(r["shares"][1] == 0.0 for r in _sw[_ref_at:])
and _cw.inverters[1].refused == 1)
check("...and the state of charge is still what tells them apart",
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
@@ -640,10 +654,10 @@ check("...and `account()` STILL turns this pack away if the ask does arrive - "
account(live=True, parked=False, soc=_cold_b.soc,
bms_floor=_cold_b.bms_soc_floor) is not None)
check("...and the WEDGED arm's B differs from it in exactly that one place - "
"which is why it is still asked for its share, and still named",
"which is why it was asked for its share until the refusal, and named",
account(live=True, parked=False, soc=_cw.inverters[1].soc,
bms_floor=_cw.inverters[1].bms_soc_floor) is None
and float(_cw.inverters[1].standing_w()) > 0)
and _sw[0]["shares"][1] > 0 and _sw[-1]["named"] == ("inverter-b",))
# ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT
# MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the
@@ -916,9 +930,13 @@ check("...and EVERY WATT IS THE SAME. Grid, site target, both shares and both "
"reported or not - which is what 'it does not act' means, stated as a "
"measurement instead of as an intention",
_power_domain(_sw) == _power_domain(_sn))
check("...the wedged unit is still being commanded its share after the trip, "
"not parked: this is NOT SAFETY-02's remedy and must not become it",
abs(float(_cw.inverters[1].target)) > 0)
_trip_at = next((i for i, r in enumerate(_sw) if r["named"]), None)
check("...the wedged unit is still commanded its share after the trip, not "
"parked by this guard: this is NOT SAFETY-02's remedy. What later takes "
"the share is SAFETY-73's refusal, which runs the same in the arm where "
"this guard never trips (the line above)",
_trip_at is not None and _ref_at is not None and _trip_at < _ref_at
and all(r["shares"][1] > 0 for r in _sw[_trip_at:_ref_at]))
check("...and nothing named `delivery` reaches the arbiter - the resolution's "
"own sentence is unchanged by the trip",
"deliver" not in _cw.reason.lower() and _cw.reason == _cn.reason)