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
+18 -4
View File
@@ -236,6 +236,15 @@ def pack_energy_of(inv):
inv.capacity_kwh, inv.max_w)
# SAFETY-73 review: the evidence must also span this long, not only
# SETTLE_CYCLES cycles. SETTLE_CYCLES was derived at the 2 s meter tick (22 s
# from its first to its twelfth cycle). A 1 s meter makes 12 cycles only 11 s,
# shorter than a healthy battery reading's lag (BATT_OBLIGED_S, 17 s, plus the
# plant's dead time), and both inverters of a site were then refused after
# ordinary load steps. At 2 s and slower this changes nothing.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
def _refused_interval(sign: int) -> tuple:
"""SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way."""
return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf)
@@ -1171,7 +1180,9 @@ class Controller:
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
"other inverters take over: BATTERY_REFUSES.", inv.name)
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
if (inv.soc is not None and inv.bms_soc_floor is not None
# Only a refusal to GIVE: a full battery that stops taking charge is
# above its cut-out level by definition and says nothing about it.
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
and float(inv.soc) > float(inv.bms_soc_floor)):
# Owner decision 3: a refusal above the configured cut-out level
# may mean that level is set wrong. One line, no safety stop.
@@ -1243,12 +1254,15 @@ class Controller:
# (a flapping load) reads ~0 through the plant's own lag, and that
# is a battery following, not refusing (TEST-19 flapping family).
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
inv.ref_count = (min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
if sign == inv.ref_sign else 1)
if sign != inv.ref_sign or not inv.ref_count:
inv.ref_count, inv.ref_since = 0, self._now_mono
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES:
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
inv.ref_since is None or self._now_mono is None
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
self._unit_refusal_start(
inv, sign,
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "