SAFETY-73 review: a refusal needs 22 s of evidence, and the cut-out warning is for giving only
checks / unit (pull_request) Successful in 10m11s
infra-pin / fresh (pull_request) Successful in 1s

- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) *
  LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both
  units 10-16 times an hour after load steps (site held 0 W). 2 s and slower
  ticks are unchanged.
- REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that
  stops taking charge is always above its cut-out level.
- test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal;
  the release/1.0 checks are restored and a span check added.
- test_refusal_units: lagged-reading and full-battery plants, two new checks,
  U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-28 02:16:24 +02:00
co-authored by Claude Opus 5.5
parent 1a9c7523c9
commit 06aa09e28c
6 changed files with 128 additions and 49 deletions
+15 -24
View File
@@ -592,21 +592,16 @@ 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 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)
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
# SAFETY-73. A unit that gives < 100 W while asked > 200 W is refused after
# SETTLE_CYCLES cycles that also span REFUSED_SPAN_S (22 s). This fixture's 14
# cycles are 0.2 s apart, so the wedged B meets the count and not the span, and
# SAFETY-42's report stands alone here. test_refusal_units.py grades the
# refusal. On a site the 185 s hold puts the refusal first.
check("SAFETY-73: the wedged arm's B is not refused in this fixture: 12 cycles "
"of evidence in under 3 s are not the 22 s a refusal needs",
_cw.inverters[1].refused == 0
and _cw.inverters[1].ref_count >= control.SETTLE_CYCLES)
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 "
@@ -654,10 +649,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 was asked for its share until the refusal, and named",
"which is why it is still asked for its share, and still named",
account(live=True, parked=False, soc=_cw.inverters[1].soc,
bms_floor=_cw.inverters[1].bms_soc_floor) is None
and _sw[0]["shares"][1] > 0 and _sw[-1]["named"] == ("inverter-b",))
and float(_cw.inverters[1].standing_w()) > 0)
# ⚠️ 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
@@ -930,13 +925,9 @@ 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))
_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("...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)
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)