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.
+17 -9
View File
@@ -295,7 +295,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W. - **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first. - **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit. - **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`. - **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported, and neither is one that gives under 100 W (SAFETY-73: it is a battery that refuses). Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode). - **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
- **Control gain** (`gain`): measured on real hardware and already at the limit. - **Control gain** (`gain`): measured on real hardware and already at the limit.
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05. - **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
@@ -550,15 +550,23 @@ control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W, needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request. is not refusing and keeps its request.
⚠️ **One inverter only.** With several inverters this hold is off. There, the **With two or more inverters** the same rule works per inverter. An inverter
battery level limit moves an empty battery's share to the others, but only if whose battery gives less than 100 W for 12 cycles while asked for more than
its “Battery cut-out level” matches where the battery really stops. A battery 200 W is asked 0 W, and the other inverters take over its share, each up to
that refuses at another level, or for another reason (cold, a battery fault), its own limits. The status page names it: *inverter-a: the battery refuses to
can still hold its last request until the grid starts to export. give power, the others take over*. It is tested at 200 W every 15 minutes, one
inverter at a time, and only while the site asks at least 200 W that way.
It still accepts charge, and a minute of charging ends the refusal. When every
inverter refuses the same way, the whole site asks 0 W as with one inverter.
If the battery refuses while its level reads above its “Battery cut-out level”,
the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
A battery that refuses is not reported as *not delivering*.
*For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and *For installers:* SAFETY-52 and SAFETY-73. The event log reads
`BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`. `BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures. N>1), and the INFO line ends in `BATTERY_REFUSES`.
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
`DECISIONS/SAFETY-73-refusal-per-inverter.md` the two-inverter ones.
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will ⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
+6
View File
@@ -226,6 +226,12 @@ CLAIM_SCOPE = {
# reaches the allocator, so a unit-scoped claim is the shape that sentence # reaches the allocator, so a unit-scoped claim is the shape that sentence
# asks for rather than an evasion of it. # asks for rather than an evasion of it.
"pack": SCOPE_UNIT, "pack": SCOPE_UNIT,
# main.py: `Claim.limit("refused", P_SAFETY, ...)` and, during a probe,
# `Claim.set("refused", P_SAFETY, +-PROBE_W)`. SAFETY-73: THIS battery gives
# nothing in one direction, measured on THIS unit's own reading. A hard unit
# limit that always intersects, so the share goes to the siblings. Filed at
# N>1 only; at N=1 SAFETY-52's law in compute() does the same job.
"refused": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns # main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when # the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
# it only shapes. §12's monthly recalibration is a PACK procedure - drain # it only shapes. §12's monthly recalibration is a PACK procedure - drain
+17
View File
@@ -317,6 +317,16 @@ class Inverter:
tripped=bool(data.get(self.key("delivery_tripped"), False)), tripped=bool(data.get(self.key("delivery_tripped"), False)),
reason=str(data.get(self.key("delivery_reason"), "") or "")) reason=str(data.get(self.key("delivery_reason"), "") or ""))
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
# take, 0 none), when that refusal or its last probe started, the
# current probe's start, and the cycles of evidence so far. Memory
# only, like SAFETY-52's: stop and start end it. Used at N>1 only.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_sign = 0
# -- store --------------------------------------------------------------- # -- store ---------------------------------------------------------------
def key(self, name: str) -> str: def key(self, name: str) -> str:
"""This unit's namespaced Store key for `name`. See store.unit_key().""" """This unit's namespaced Store key for `name`. See store.unit_key()."""
@@ -411,6 +421,13 @@ class Inverter:
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
else "") else "")
def refusal_note(self) -> str:
"""SAFETY-73's status row while this unit's battery refuses."""
if not self.refused:
return ""
return messages.row_battery_refuses(self.name, self.refused,
self.probe_from is not None)
def link_note(self, now: datetime) -> str: def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say. """One line for the status page. Empty when there is nothing to say.
+191 -7
View File
@@ -28,6 +28,7 @@ import contextlib
import functools import functools
import json import json
import logging import logging
import math
import signal import signal
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -234,6 +235,11 @@ def pack_energy_of(inv):
inv.capacity_kwh, inv.max_w) inv.capacity_kwh, inv.max_w)
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)
def why_key(winner: str, claims) -> str: def why_key(winner: str, claims) -> str:
"""The key of `messages.WHY_WINNER` for a resolution's winner. """The key of `messages.WHY_WINNER` for a resolution's winner.
@@ -1121,6 +1127,19 @@ class Controller:
""" """
t = self.tuning t = self.tuning
standing = inv.standing_w() standing = inv.standing_w()
if len(self.inverters) > 1:
# SAFETY-73: at N>1 the same trip refuses THIS unit only.
if ((self.auto or self.shadow) and not inv.refused
and inv.ref_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(standing) > _ctl.PROBE_W):
self._now_mono = now_mono
self._unit_refusal_start(
inv, 1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W while asked "
f"{standing:+.0f} W")
return
if (len(self.inverters) == 1 and (self.auto or self.shadow) if (len(self.inverters) == 1 and (self.auto or self.shadow)
and not self.batt_refused and not self.batt_refused
and self.sat_count >= t.saturation_cycles and self.sat_count >= t.saturation_cycles
@@ -1132,6 +1151,111 @@ class Controller:
f"stale reading {inv.batt_fresh.held:+.0f} W " f"stale reading {inv.batt_fresh.held:+.0f} W "
f"while asked {standing:+.0f} W") f"while asked {standing:+.0f} W")
# --- SAFETY-73: the same refusal, per inverter, at N>1 -------------------
# SAFETY-52's decisions applied to one unit of several: a unit that gives
# < REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in
# that direction by a P_SAFETY unit limit, so the allocator hands its share
# to the others; it is probed at PROBE_W every PROBE_EVERY_S, one unit at a
# time. The ask is PROBE_W, not `saturation_w`: a unit's share is often
# small (a 400 W night load split two ways) and SAFETY-43 would otherwise
# name the empty battery as not delivering. DECISIONS/SAFETY-73.
def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None:
inv.refused, inv.refused_at = sign, self._now_mono
inv.probe_from, inv.ref_count = None, 0
self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}",
hint=messages.hint("unit_refuses", unit=inv.name))
_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
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.
_LOG.warning("%s refuses power above its battery cut-out level, "
"check its cut-out level: REFUSES_ABOVE_CUT_OUT.",
inv.name)
_LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level "
"%.0f %%, cut-out level %.0f %%.", inv.name,
float(inv.soc), float(inv.bms_soc_floor))
def _unit_refusal_end(self, inv, why: str) -> None:
if not inv.refused:
return
inv.refused, inv.refused_at, inv.probe_from = 0, None, None
inv.ref_count = 0
self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}",
hint=messages.hint("unit_gives", unit=inv.name))
_LOG.info("%s: the battery refusal ends, it is asked for power again: "
"%s.", inv.name, why)
# The site target may stand above what was placed (the refused share
# was unmet). Restart it from the watts on the wire, or the reopened
# unit is handed the parked remainder at once (phase A: -1059 W).
placed = float(sum(i.standing_w() for i in self.inverters))
self.target = self.i_w = placed
self.sat_count = 0
def _unit_probe(self, inv) -> bool:
"""True while `inv` may try PROBE_W. One unit probes at a time."""
now = self._now_mono
if not inv.refused or inv.refused_at is None or now is None:
return False
if inv.probe_from is not None:
if now - inv.probe_from < _ctl.PROBE_S:
return True
inv.probe_from, inv.refused_at = None, now
return False
if now - inv.refused_at < _ctl.PROBE_EVERY_S or any(
i.probe_from is not None for i in self.inverters):
return False
inv.probe_from = now
return True
def _unit_refusals(self) -> None:
"""Engage and end each unit's refusal from its own reading."""
for inv in self.inverters:
b = inv.batt
if inv.refused:
if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W:
self._unit_refusal_end(
inv, "the probe was answered"
if inv.probe_from is not None
else "the battery gives power")
continue
# The other direction: the battery takes PROBE_ANSWER_W or more
# the other way (charge at the floor) for SETTLE_CYCLES. Counted
# on its own reading, so one cycle of the loop overshooting
# through zero does not hand it its share back.
inv.ref_count = (inv.ref_count + 1 if b is not None and
-inv.refused * b >= _ctl.PROBE_ANSWER_W else 0)
if inv.ref_count >= _ctl.SETTLE_CYCLES:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if b is None:
continue # a gap keeps the count: the gate's trip seeds
standing = inv.standing_w()
sign = 1 if standing > 0 else -1
# One direction throughout: a command that swaps sign every cycle
# (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)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES:
self._unit_refusal_start(
inv, sign,
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
f"for {_ctl.SETTLE_CYCLES} cycles")
def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
signs = {inv.refused for inv in self.inverters}
return signs.pop() if len(signs) == 1 and 0 not in signs else None
def log_event(self, msg: str, hint: str | None = None) -> None: def log_event(self, msg: str, hint: str | None = None) -> None:
"""`msg` goes on the action log, the add-on's own technical record. """`msg` goes on the action log, the add-on's own technical record.
`hint` is `messages.hint()`'s plain words for the same event, and is `hint` is `messages.hint()`'s plain words for the same event, and is
@@ -2516,7 +2640,8 @@ class Controller:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
return [n for n in return [n for n in
[x for inv in self.inverters [x for inv in self.inverters
for x in (inv.link_note(now), inv.stale_note())] for x in (inv.link_note(now), inv.stale_note(),
inv.refusal_note())]
if n] if n]
async def cycle(self) -> None: async def cycle(self) -> None:
@@ -2810,7 +2935,13 @@ class Controller:
# what part 6 grades instead is that WIDENING the window # what part 6 grades instead is that WIDENING the window
# changes the answer. # changes the answer.
live=inv.is_live(now)[0], live=inv.is_live(now)[0],
parked=inv.sign_wd.tripped, # SAFETY-73: a refusing unit is asked 0 W on purpose, and
# one asked under PROBE_W that gives under 100 W is a
# refusal too small to call (its sibling covers the meter).
parked=inv.sign_wd.tripped or bool(inv.refused) or (
n_units > 1 and inv.batt is not None
and abs(float(standing[inv.name])) <= _ctl.PROBE_W
and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W),
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not # ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
# the site's `peak_soc_floor` either - that one bounds a # the site's `peak_soc_floor` either - that one bounds a
# money-saving discharge only, and grading a pack against it # money-saving discharge only, and grading a pack against it
@@ -3422,6 +3553,16 @@ class Controller:
self._refusal_auto = bool(self.auto) self._refusal_auto = bool(self.auto)
self._refusal_end("control started" if self.auto self._refusal_end("control started" if self.auto
else "control stopped") else "control stopped")
for inv in (self.inverters if len(self.inverters) > 1 else ()):
self._unit_refusal_end(inv, "control started" if self.auto
else "control stopped")
# SAFETY-73: each unit's own refusal, engaged and ended on its own
# reading, before the loop reads `self.target` (an end reseeds it).
_probing = {}
if len(self.inverters) > 1 and self.inputs_ok:
self._unit_refusals()
_probing = {inv.name: self._unit_probe(inv)
for inv in self.inverters if inv.refused}
if (self.auto or self.shadow) and self.inputs_ok: if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last # ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware # wish. If something outranked the loop, that is what the hardware
@@ -3446,6 +3587,8 @@ class Controller:
if self.loop_w is not None and self.target != self.loop_w: if self.loop_w is not None and self.target != self.loop_w:
self._refusal_end("another writer") self._refusal_end("another writer")
probe = self._refusal_probe() probe = self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
# The anti-windup test inside compute() is # The anti-windup test inside compute() is
# `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE # `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE
@@ -3476,7 +3619,10 @@ class Controller:
i_w=self.i_w, i_w=self.i_w,
# SAFETY-52 at one inverter only: with several, OPT-09's pack # SAFETY-52 at one inverter only: with several, OPT-09's pack
# limit already closes an empty battery's discharge end. # limit already closes an empty battery's discharge end.
refused=self.batt_refused if len(self.inverters) == 1 else None, # SAFETY-73: at N>1 the site law holds 0 W only while every
# unit refuses the same way. Otherwise None, the old law.
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe, probe=probe,
) )
if decision.frozen and self.sat_count < self.tuning.saturation_cycles: if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
@@ -3486,7 +3632,8 @@ class Controller:
self.sat_count = decision.sat_count self.sat_count = decision.sat_count
self.i_w = decision.i_w self.i_w = decision.i_w
self.loop_w = decision.target_w self.loop_w = decision.target_w
self._refusal_update(decision, loop_prev, site_batt, probe) if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe)
# --- TEL-10 ------------------------------------------------------ # --- TEL-10 ------------------------------------------------------
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT # ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
# THE CARD IS. `self.i_w` is carried between cycles and published # THE CARD IS. `self.i_w` is carried between cycles and published
@@ -3585,8 +3732,44 @@ class Controller:
# ability to DELIVER rests on this argument - a weight cannot stop a # ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its # unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains. # sibling. The `pack` claim filed above is the half that constrains.
resolution = resolve(claims, units=self.units, live=self.live_units(), # --- SAFETY-73: each refusing unit's own P_SAFETY claim ---------------
weights=self.pack_weights, tick=self.alloc_tick) # A hard unit limit that always intersects (OPT-11's P_MODE layer sits
# inside it): closed in the refused direction, so the allocator hands
# the share to the others within their own limits. During its probe it
# is a pin at PROBE_W and the others carry the rest.
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
weights=self.pack_weights, tick=self.alloc_tick)
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way. Below that a sibling would carry
# the opposite direction (circulation), so the pin goes back to the
# closed limit and the split is resolved once more.
_void = [k for k, c in enumerate(claims)
if c.source == "refused" and c.kind == "set"
and c.value * resolution.target_w < _ctl.PROBE_W ** 2]
if not _void:
break
for k in _void:
claims[k] = Claim.limit(
"refused", P_SAFETY,
*_refused_interval(1 if claims[k].value > 0 else -1),
_ctl.REFUSED, actuator=claims[k].actuator)
self.allocation = resolution.allocation self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL -------------- # --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe # Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -5114,7 +5297,8 @@ class Controller:
_now = datetime.now(timezone.utc) _now = datetime.now(timezone.utc)
for inv in self.inverters: for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]), for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
(inv.stale_note(), False)): (inv.stale_note(), False),
(inv.refusal_note(), False)):
if note: if note:
out.append({"ok": False, "warn": not red, "text": note}) out.append({"ok": False, "warn": not red, "text": note})
+19
View File
@@ -786,6 +786,14 @@ def row_battery_stuck(unit) -> str:
f"for now") f"for now")
def row_battery_refuses(unit, sign, probing) -> str:
"""SAFETY-73. One inverter's battery refuses, the others take its share."""
way = "give" if sign > 0 else "take"
if probing:
return f"{unit}: testing if the battery will {way} power again"
return f"{unit}: the battery refuses to {way} power, the others take over"
# The top of the page: why the battery does what it does (the "Why" line). # The top of the page: why the battery does what it does (the "Why" line).
WHY_WINNER = { WHY_WINNER = {
"loop": "Keeping the grid near zero", "loop": "Keeping the grid near zero",
@@ -805,6 +813,8 @@ WHY_WINNER = {
# SAFETY-52: the loop's two words for a battery that gives nothing. # SAFETY-52: the loop's two words for a battery that gives nothing.
"battery refuses": "The battery refuses to give power", "battery refuses": "The battery refuses to give power",
"battery probe": "Testing if the battery gives power again", "battery probe": "Testing if the battery gives power again",
# SAFETY-73: a refusing inverter's own claim, closed or probing.
"refused": "A battery refuses power, the others take over",
} }
WHY_LIMIT = { WHY_LIMIT = {
"peak(quarter peak)": "the peak limit", "peak(quarter peak)": "the peak limit",
@@ -815,6 +825,7 @@ WHY_LIMIT = {
"pack": "the battery level", "pack": "the battery level",
"reserve": "the battery reserve", "reserve": "the battery reserve",
"maintenance": "battery maintenance", "maintenance": "battery maintenance",
"refused": "a battery that refuses power",
} }
@@ -857,6 +868,8 @@ HINTS = {
"battery_back": "{unit}: battery power reading is used again", "battery_back": "{unit}: battery power reading is used again",
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W", "battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
"battery_gives": "The battery refusal ended, control asks for power again", "battery_gives": "The battery refusal ended, control asks for power again",
"unit_refuses": "{unit}: the battery refuses power, the others take over",
"unit_gives": "{unit}: the battery refusal ended, it is asked for power again",
"bad_value": "The add-on made an invalid power value, inverters set to 0 W", "bad_value": "The add-on made an invalid power value, inverters set to 0 W",
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W", "write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
"all_blocked": "A power request was blocked, inverters set to 0 W", "all_blocked": "A power request was blocked, inverters set to 0 W",
@@ -1027,6 +1040,12 @@ def status_samples() -> list:
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500, ("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
shadow=True)), shadow=True)),
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")), ("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", -1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
True)),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)), ("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
] ]
+8 -4
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device") Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print() print()
print("ARB-03 2. the split: eleven sources, each classified where it is BUILT") print("ARB-03 2. the split: twelve sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here # The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same # so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's # sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -189,16 +189,20 @@ UNIT_SOURCES = {
"is added, `peak_soc_floor` is not consulted, the SITE target " "is added, `peak_soc_floor` is not consulted, the SITE target "
"is untouched, and main.py does not file it at all at a " "is untouched, and main.py does not file it at all at a "
"roster of one", "roster of one",
# SAFETY-73.
"refused": "THIS battery's own reading gave nothing against THIS unit's "
"own standing command, so that direction is closed on THIS "
"unit and its share goes to the others. Filed at N>1 only",
} }
for _src, _why in sorted(SITE_SOURCES.items()): for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}") print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()): for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}") print(f" unit {_src:<11} {_why}")
check("all ELEVEN claim sources are classified, and only those eleven", check("all TWELVE claim sources are classified, and only those twelve",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES)) set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims", check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES)) {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the five that are about a PACK, a DEVICE or one write are per-inverter", check("the six that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES)) {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() " check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the " "raises rather than defaulting to the site, because defaulting to the "
@@ -216,7 +220,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE)) all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise", check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE)) all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the five " check("with TWO the identity breaks and the split becomes real: the six "
"per-inverter sources route to the unit this controller drives, the six " "per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection", "site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE} {s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
+25 -7
View File
@@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone
from named_absence import given, present from named_absence import given, present
from app import messages from app import control, messages
from app.control import Tuning from app.control import Tuning
from app.delivery import ( from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE, 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 " "cards meet here from opposite sides: OPT-09 stops the ASK, this file "
"grades the ALARM, and neither can do the other's half", "grades the ALARM, and neither can do the other's half",
_pw != _pf _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", check("...and the state of charge is still what tells them apart",
[r["soc"] for r in _sw] != [r["soc"] for r in _sf]) [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 " 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, account(live=True, parked=False, soc=_cold_b.soc,
bms_floor=_cold_b.bms_soc_floor) is not None) 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 - " 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, account(live=True, parked=False, soc=_cw.inverters[1].soc,
bms_floor=_cw.inverters[1].bms_soc_floor) is None 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 # ⚠️ 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 # 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 " "reported or not - which is what 'it does not act' means, stated as a "
"measurement instead of as an intention", "measurement instead of as an intention",
_power_domain(_sw) == _power_domain(_sn)) _power_domain(_sw) == _power_domain(_sn))
check("...the wedged unit is still being commanded its share after the trip, " _trip_at = next((i for i, r in enumerate(_sw) if r["named"]), None)
"not parked: this is NOT SAFETY-02's remedy and must not become it", check("...the wedged unit is still commanded its share after the trip, not "
abs(float(_cw.inverters[1].target)) > 0) "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 " check("...and nothing named `delivery` reaches the arbiter - the resolution's "
"own sentence is unchanged by the trip", "own sentence is unchanged by the trip",
"deliver" not in _cw.reason.lower() and _cw.reason == _cn.reason) "deliver" not in _cw.reason.lower() and _cw.reason == _cn.reason)
+15 -13
View File
@@ -2655,7 +2655,7 @@ check("...and so does the persistent notification, which outlives the page and "
# wants from you, not an edit. # wants from you, not an edit.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation", "arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack"} "sign_site", "pack", "refused"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so # The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a # that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan. # silent gap in the scan.
@@ -2697,7 +2697,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather " "- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly " "than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed) "vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these ELEVEN. THIS " check("...and the sources that cycle can file are EXACTLY these TWELVE. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim " "is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC " "list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it " "reserve is a new source. Filing one takes this line red on the line it "
@@ -2848,7 +2848,8 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"} == {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack"}) == {"device", "supervised", "failsafe", "maintenance", "pack",
"refused"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a # The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve # real cycle files is a site claim. Observed on the claims main.py's own resolve
@@ -2945,7 +2946,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"rest of the roster goes on pushing the same way - and this line goes red", "rest of the roster goes on pushing the same way - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a", _routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a", "failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a", "pack": "inverter-a", "refused": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE, "loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE, "arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE}) "circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
@@ -3027,13 +3028,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]] if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: " print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing)) + ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through " check("all THIRTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing " "`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted " "of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being " "the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, " "silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once", "and 10f is in this file because that already happened once",
len(_direct) == 11 len(_direct) == 13
and all(a is not None and a.startswith("self.actuator_of('") and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing)) for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE " check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
@@ -3050,15 +3051,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a " "site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` " "`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong " "files inverter B's rating on inverter A, where it binds the wrong "
"share. Six of the eleven appends are SCOPE_UNIT - `device`, " "share. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and " "`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
"all six pass one. " "SAFETY-73's two `refused` - and all eight pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on " "SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is " "the site, beside `circulation`, because the number that tripped it is "
"the site target", "the site target",
_unit_sources_unrouted == [] _unit_sources_unrouted == []
and len([r for r in _routing and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6) if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 8)
check("...and the only appends this pin does NOT reach are the two that file a " check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no " "claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where " "`actuator=` at the call site to mis-type. Their routing is pinned where "
@@ -3067,7 +3068,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py", "arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS) sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends)) and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the twelve appends account for exactly the eleven " check("...and between them the fifteen appends account for exactly the twelve "
"sources 10f pins, so the routing pin and the source set are one scan " "sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other", "and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values()) {s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3119,7 +3120,7 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
("supervised", "inverter-a"), ("supervised", "inverter-a"),
("supervised", "inverter-b")]) ("supervised", "inverter-b")])
check("...and naming those three is not an accident of this fixture but the " check("...and naming those three is not an accident of this fixture but the "
"reason the static pin exists - SEVEN of the eleven sources need a state " "reason the static pin exists - EIGHT of the twelve sources need a state "
"no check in this file reaches. SAFETY-31's `circulation` is one of the " "no check in this file reaches. SAFETY-31's `circulation` is one of the "
"seven: it is filed only while the site is MEASURED to be circulating, " "seven: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is. SAFETY-30's `sign_site` is another, and it " "which no fixture here is. SAFETY-30's `sign_site` is another, and it "
@@ -3127,7 +3128,8 @@ check("...and naming those three is not an accident of this fixture but the "
"standing opposed to the meter for its whole window", "standing opposed to the meter for its whole window",
{c.source for c in _cl2} {c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak", == CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site"}) "arbitrage", "circulation", "sign_site",
"refused"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three # ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about # checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects # WHOSE NUMBERS the bound is built from - and those are two different defects
+480
View File
@@ -0,0 +1,480 @@
"""Runnable check for SAFETY-73. `python3 test_refusal_units.py`
SAFETY-52 per inverter. With two or more inverters, a battery that refuses
(gives < 100 W for 12 cycles while asked > 500 W, or trips the stale-reading
gate on a ~0 W reading) is closed in that direction by its own P_SAFETY limit,
so the allocator hands its share to the others. It is probed at 200 W every
15 minutes, one inverter at a time. When every inverter refuses the same way
the site holds 0 W. When a refusal ends the site target restarts from the watts
on the wire. DECISIONS/SAFETY-73 has the record.
Drives the REAL `Controller` (read_inputs + cycle, the real freshness gate,
allocator, OPT-09 pack claim and SAFETY-42 detector) with two inverters in
simulated time. At the cut-out a battery reads -4 W for any request >= -4 W
and accepts charge. Two register shapes: FLAT at -4 W (the 09-06 capture) and
JITTER +-3 W (the gate cannot arm). `OLD` runs the same fixture with the
per-unit refusal switched off, which is 652097a's behaviour at N>1.
Mutations (MUTATION.md, `python3 -B`, copies purged of `__pycache__`), graded
over this file / test_delivery / test_refusal. Every one is red here:
# mutation reds
--- --------------------------------------------------------- -------
U1 main.py: the per-unit engage clause never fires 7/2/0
U2 main.py: the N>1 stale-reading seed returns at once 14/0/0
U3 main.py: no `refused` claim is filed 13/2/0
U4 main.py: a probe never pins (always the closed limit) 7/0/0
U5 main.py: a probe pin stands whatever the site target 1/0/0
U6 main.py: no reseed of the site target when a refusal ends 1/0/0
U7 main.py: no site hold when every inverter refuses 2/0/0
U8 main.py: the other-direction exit removed (charge counter) 1/0/0
U9 main.py: the delivery exit removed 3/0/0
U10 main.py: probes are no longer one at a time 1/0/0
U11 main.py: the above-cut-out WARNING condition inverted 1/0/0
U12 main.py: SAFETY-42's account no longer sees a small ask 1/0/0
U13 main.py: a unit engages above `saturation_w`, not PROBE_W 1/0/0
On 652097a itself this file stops at the first sample (`Inverter` has no
`refused`); its OLD arms reproduce that tree's N>1 behaviour instead, and
every PRECONDITION line is graded against them.
"""
import asyncio
import logging
import sys
from datetime import datetime, timedelta, timezone
from app import control
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W
from app.main import Controller
from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve
import app.main as m
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
TICK = 5.0
CUT_W = -4.0
H = 3600.0
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
UNITS = ("a", "b")
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Hass:
"""A house, one meter, and two batteries that follow unless cut out."""
def __init__(self, load, cut, soc, jitter):
self.load, self.cut, self.soc, self.jitter = load, cut, soc, jitter
self.cmd = {u: 0.0 for u in UNITS}
self.t = 0.0
def batt(self, u):
c = self.cmd[u]
if self.cut[u](self.t) and c >= CUT_W:
if self.jitter:
return CUT_W + ((int(self.t / TICK) % 3) - 1) * 3.0
return CUT_W
return float(round(c))
def grid(self):
return self.load(self.t) - sum(self.batt(u) for u in UNITS)
async def limits(self, e):
return (-2000.0, 2000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.grid()
u = e[-1]
if e.startswith("sensor.soc"):
v = self.soc[u]
return v(self.t) if callable(v) else v
if e.startswith("sensor.batt"):
return self.batt(u)
return None
async def set_number(self, e, v):
self.cmd[e[-1]] = float(v)
return True
def _off(c):
"""652097a's N>1 behaviour: nothing refuses per unit."""
c._unit_refusals = lambda: None
c._unit_refusal_start = lambda *a, **k: None
def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
extra=None):
"""Real cycles every TICK of simulated time. Rows are dicts per cycle."""
async def go():
hass = _Hass(load, cut, soc or {"a": 50.0, "b": 50.0}, jitter)
inv = [{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}",
"batt_entity": f"sensor.batt_{u}",
"max_w": 2000, "bms_soc_floor": 10} for u in UNITS]
o = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": inv, "gain": 0.6, "slew_w": 400,
"target_grid_w": -10, "deadband_w": 15, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15, "auto_start": True}
o.update(extra or {})
c = Controller(o, hass, _Store(), _Pub(), stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -2000.0, 2000.0
c.auto = True
if old:
_off(c)
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
rows = []
trips = {u: 0 for u in UNITS}
was = {u: False for u in UNITS}
pending = dict(events or {})
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
for et in sorted(pending):
if et <= t:
pending.pop(et)(c)
await c.read_inputs(now_mono=t)
for u, i in zip(UNITS, c.inverters):
s = i.batt_fresh.stale
trips[u] += s and not was[u]
was[u] = s
await c.cycle()
rows.append(dict(
t=t, grid=hass.grid(), site=c.target, reason=c.loop_reason,
a=hass.cmd["a"], b=hass.cmd["b"],
ra=c.inverters[0].refused, rb=c.inverters[1].refused,
da=c.inverters[0].delivery_wd.tripped,
db=c.inverters[1].delivery_wd.tripped))
t += TICK
finally:
m.datetime = real_dt
return rows, trips, c
return asyncio.run(go())
def after(rows, t0, t1=None):
return [r for r in rows if r["t"] >= t0 and (t1 is None or r["t"] < t1)]
def mean(rows, k):
return sum(r[k] for r in rows) / len(rows) if rows else float("nan")
def never(t):
return False
def cut_at(t0, t1=None):
return lambda t: t >= t0 and (t1 is None or t < t1)
def probes(rows, k):
"""Start times of runs where refused unit `k` is asked exactly PROBE_W."""
out, prev = [], False
for r in rows:
on = abs(r[k]) == PROBE_W and r["r" + k] != 0
if on and not prev:
out.append(r["t"])
prev = on
return out
SHAPES = (("flat", False), ("jitter", True))
# ==============================================================================
print("1. case a: house 3000 W, both give power, A cuts out at 120 s")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, H,
jitter=jit)
old, otr, _ = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, H,
jitter=jit, old=True)
tail_n, tail_o = after(new, 300), after(old, 300)
held = [r for r in tail_n if abs(r["a"]) != PROBE_W]
print(f" {shape}: old B {mean(tail_o, 'b'):.0f} W grid "
f"{mean(tail_o, 'grid'):+.0f} W trips A {otr['a']}; new B "
f"{mean(tail_n, 'b'):.0f} W grid {mean(tail_n, 'grid'):+.0f} W "
f"trips A {ntr['a']}")
check(f"{shape}: PRECONDITION: on the old law B never takes over (mean "
f"under 1950 W) while A gives nothing",
mean(tail_o, "b") < 1950 and mean(tail_o, "grid") > 1050)
check(f"{shape}: A is refused and asked 0 W outside its probes, B carries "
f"its rating, and the grid imports only what B cannot give",
cn.inverters[0].refused == 1 and all(r["a"] == 0.0 for r in held)
and min(r["b"] for r in tail_n) == 2000.0
and abs(mean(tail_n, "grid") - 1004) < 20)
check(f"{shape}: A's stale-reading gate trips at most once, the site is "
f"never stopped after the first 5 minutes, and SAFETY-42 does not "
f"report A as not delivering",
ntr["a"] <= 1
and all(r["reason"] != "not-running" for r in tail_n)
and not any(r["da"] for r in new))
# ==============================================================================
print("2. case b: house 1200 W, which B alone can carry, A cuts out")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 1200.0, {"a": cut_at(120), "b": never}, H,
jitter=jit)
old, _, _ = run(lambda t: 1200.0, {"a": cut_at(120), "b": never}, H,
jitter=jit, old=True)
tail_n, tail_o = after(new, 300), after(old, 300)
imp = sum(max(0.0, r["grid"]) for r in tail_n) * TICK / H
print(f" {shape}: old grid {mean(tail_o, 'grid'):+.0f} W, new grid "
f"{mean(tail_n, 'grid'):+.0f} W, new import {imp:.0f} Wh")
check(f"{shape}: PRECONDITION: the old law leaves a standing import",
mean(tail_o, "grid") > 250)
check(f"{shape}: B takes over the whole house and the grid sits near its "
f"target", abs(mean(tail_n, "grid") + 10) < 25 and imp < 10)
pa = probes(new, "a")
in_probe = [r for r in new
if any(p <= r["t"] < p + PROBE_S for p in pa)]
check(f"{shape}: A is probed at 200 W every 15 minutes while B carries "
f"the rest, and the two never pull opposite ways",
len(pa) >= 3
and all(abs(q - p - PROBE_EVERY_S - PROBE_S) <= 2 * TICK
for p, q in zip(pa, pa[1:]))
and all(r["b"] > 0 for r in in_probe)
and all(r["a"] * r["b"] >= 0 for r in new))
# ==============================================================================
print("3. case c: both cut out; the site holds 0 W, probes one at a time")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 3000.0, {"a": cut_at(120), "b": cut_at(150)},
1.5 * H, jitter=jit)
old, _, _ = run(lambda t: 3000.0, {"a": cut_at(120), "b": cut_at(150)},
1.5 * H, jitter=jit, old=True)
tail_n, tail_o = after(new, 600), after(old, 600)
pa, pb = probes(new, "a"), probes(new, "b")
print(f" {shape}: old mean asks A/B {mean(tail_o, 'a'):.0f}/"
f"{mean(tail_o, 'b'):.0f} W; new {mean(tail_n, 'a'):.0f}/"
f"{mean(tail_n, 'b'):.0f} W, probes A {len(pa)} B {len(pb)}")
check(f"{shape}: PRECONDITION: the old law keeps asking two empty "
f"batteries", mean(tail_o, "a") > 150 and mean(tail_o, "b") > 150)
check(f"{shape}: the site holds 0 W: the loop says `battery refuses` or "
f"`battery probe` and each battery is asked 0 W or its probe",
all(r["reason"] in (control.REFUSED, control.PROBING)
for r in tail_n)
and all(r["a"] in (0.0, PROBE_W) and r["b"] in (0.0, PROBE_W)
for r in tail_n))
check(f"{shape}: both batteries are probed, never at the same time",
len(pa) >= 3 and len(pb) >= 3
and not any(r["a"] and r["b"] for r in new if r["t"] > 300))
# ==============================================================================
print("4. recovery: the reseed stops the parked site target reaching the grid")
# ==============================================================================
# Both cut, the house drops to 940 W at 1 h, A recovers at 1.5 h with no sign
# change. Phase A, with the envelope but no reseed and no site hold: -1059 W.
ld = (lambda t: 3000.0 if t < H else 940.0)
for shape, jit in SHAPES:
rows, _, c = run(ld, {"a": cut_at(120, 1.5 * H), "b": cut_at(150)},
2 * H, jitter=jit)
win = after(rows, 1.5 * H, 1.5 * H + 900)
worst = min(r["grid"] for r in win)
print(f" {shape}: worst grid after A recovers {worst:+.0f} W; A "
f"refused {c.inverters[0].refused}")
check(f"{shape}: A's recovery is found (at its next probe at the latest) "
f"and never exports more than 250 W",
c.inverters[0].refused == 0 and worst > -250
and any("BATTERY REFUSES ENDED inverter-a" in e for e in c.events))
# Case a: B at its rating, the site target parked at 3600 W while the house
# still imports; the house falls to 2500 W and A recovers. Without the reseed A
# is handed the parked 1600 W at once, 1100 W more than the house needs.
ld2 = (lambda t: 3000.0 if t < 0.5 * H else 2500.0)
rows, _, c = run(ld2, {"a": cut_at(120, 0.75 * H), "b": never}, 1.5 * H)
win = after(rows, 0.75 * H, 0.75 * H + 900)
print(f" a: worst grid when A recovers {min(r['grid'] for r in win):+.0f} W")
check("case a: a recovery hands A no parked site target (worst export under "
"250 W)", min(r["grid"] for r in win) > -250
and any("BATTERY REFUSES ENDED inverter-a" in e for e in c.events))
# ==============================================================================
print("5. exits, words and the circulation guard")
# ==============================================================================
# Solar: the house exports from 30 min, so the loop wants to charge.
rows, _, c = run(lambda t: 3000.0 if t < 0.5 * H else -2500.0,
{"a": cut_at(120), "b": never}, H)
check("the grid asking the other direction lets A charge at once (its limit "
"closes only the refused direction) and a minute of charge ends the "
"refusal",
c.inverters[0].refused == 0 and min(r["a"] for r in after(rows, 0.6 * H))
< -500 and any("the other way" in e for e in c.events))
def _stop(c):
c.auto = False
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 0.5 * H,
events={1200.0: _stop})
check("stopping control ends the refusal",
c.inverters[0].refused == 0
and any("control stopped" in e for e in c.events))
# A small house: the site asks under 200 W, so a probe may not pin A (B would
# have to charge while A gives power). The probe waits instead.
rows, _, c = run(lambda t: 3000.0 if t < 600 else 120.0,
{"a": cut_at(120), "b": never}, 1.2 * H)
check("with the site asking under 200 W no probe is sent and no inverter is "
"ever asked the opposite way to the other",
c.inverters[0].refused == 1
and probes(after(rows, 700), "a") == []
and all(r["a"] * r["b"] >= 0 for r in rows)
and not any("ARBITER" in e for e in c.events))
# The hold binds every strategy's ask: a peak-guard SET at P_PEAK on the site
# is still placed around a refusing unit's closed limit.
_res = resolve([Claim.set("peak", P_PEAK, 3000.0, "quarter peak"),
Claim.set("loop", P_LOOP, 500.0, "tracking"),
Claim.limit("supervised", P_SAFETY, -2000.0, 2000.0,
actuator="inverter-a"),
Claim.limit("supervised", P_SAFETY, -2000.0, 2000.0,
actuator="inverter-b"),
Claim.limit("refused", P_SAFETY, hi=0.0,
reason=control.REFUSED, actuator="inverter-a")],
units=("inverter-a", "inverter-b"),
live=("inverter-a", "inverter-b"))
check("a P_SAFETY refusal limit binds a higher-priority strategy too: the "
"peak guard's 3000 W goes to B, A gets 0 W",
_res.shares.get("inverter-a") == 0 and _res.shares.get("inverter-b")
== 2000)
class _Grab(logging.Handler):
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
self.lines.append((record.levelno, record.getMessage()))
_g = _Grab()
_lg = logging.getLogger("goodwe")
_lv = _lg.level
_lg.setLevel(logging.INFO)
_lg.addHandler(_g)
run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600)
run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600,
soc={"a": 10.0, "b": 50.0})
_lg.removeHandler(_g)
_lg.setLevel(_lv)
warns = [x for lv, x in _g.lines if lv == logging.WARNING
and "REFUSES_ABOVE_CUT_OUT" in x]
infos = [x for lv, x in _g.lines if lv == logging.INFO
and "BATTERY_REFUSES" in x]
print(f" {warns} / {infos}")
check("a refusal above the configured cut-out level logs INFO and one WARNING "
"naming the inverter, and a battery at its cut-out logs neither "
"(OPT-09's limit closes it first)",
len(warns) == 1 and warns[0].startswith("inverter-a ")
and len(infos) == 1 and infos[0].startswith("inverter-a:")
and len(warns[0].split()) <= 25)
# A night load of 400 W: each unit is asked about 400 W once the loop has
# made up A's missing half, under `saturation_w`. Engaging at PROBE_W is what
# refuses A here; at 500 W it would keep a phantom share that SAFETY-43 names.
rows, _, c = run(lambda t: 400.0, {"a": cut_at(0), "b": never}, 1200)
check("a 400 W night load: A is refused at a share under 500 W, B carries the "
"house and nothing reports A as not delivering",
c.inverters[0].refused == 1 and not any(r["da"] for r in rows)
and abs(mean(after(rows, 300), "grid") + 10) < 25
and mean(after(rows, 300), "b") > 380)
# A night load of 150 W: A's share never passes PROBE_W, so it is never
# refused, and SAFETY-43 must not name it as not delivering either: a battery
# asked under 200 W that gives under 100 W is a refusal too small to call.
rows, _, c = run(lambda t: 150.0, {"a": cut_at(0), "b": never}, 1200)
check("a small share on an empty battery is neither refused nor reported as "
"not delivering, and the grid stays near its target",
c.inverters[0].refused == 0 and not any(r["da"] for r in rows)
and abs(mean(after(rows, 300), "grid") + 10) < 25)
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600)
notes = c.liveness_notes()
check("the status page names the refusing inverter",
any(n.startswith("inverter-a: the battery refuses to give power")
for n in notes))
# Shadow mode writes nothing, so nothing is asked and nothing can refuse.
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 1200,
extra={"shadow_mode": True})
check("shadow mode: no inverter is refused and both stay at 0 W",
c.inverters[0].refused == 0 and c.inverters[1].refused == 0
and all(r["a"] == 0.0 and r["b"] == 0.0 for r in rows))
# ==============================================================================
print("6. one inverter is untouched")
# ==============================================================================
def _boom(*a, **k):
raise AssertionError("SAFETY-73 code ran at N=1")
_saved = {k: getattr(Controller, k) for k in (
"_unit_refusals", "_unit_refusal_start", "_unit_refusal_end",
"_unit_probe", "_site_refused")}
for k in _saved:
setattr(Controller, k, _boom)
try:
import test_refusal as _t1
_one_ok = not _t1.fails
except (AssertionError, SystemExit) as e:
_one_ok = isinstance(e, SystemExit) and not e.code
finally:
for k, v in _saved.items():
setattr(Controller, k, v)
check("SAFETY-52's whole single-inverter suite passes with every SAFETY-73 "
"method made to raise, so none of it runs at N=1", _one_ok)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")