Files
adminandClaude Opus 5.5 d017fc67bf
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Failing after 1s
UX-08 phase 2b: short, plain status rows and page banners
Every Commissioning row and every page banner is now worded in
app/messages.py (row_*, banner_*): one sentence of at most 15 words, a red
row up to three sentences so a latch still says how it clears, banners at
most 12 words. No row names a Home Assistant entity: sensors by their
setting, solar sources by their place in the list. Reason codes and limits
stay on the log line and event list; rows keep the numbers a reader acts on.

UX-07 item 1: ClampWatchdog counts over-rating and integrator-bound
engagements separately, and after an episode each count shows under its own
alarm's title instead of both under "output clamp".

No row colour, visibility or banner order changed. Rows carry an "id" so the
tests pin meaning rather than wording. The row and banner kinds leave the
allowlist (200 -> 168, cap equal). DOCS.md gains "Status page"; choices are in
DECISIONS/UX-08-short-notifications.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 02:12:10 +02:00

1299 lines
65 KiB
Python

"""Runnable check for SAFETY-42 - a unit that stops delivering its share.
`python3 test_delivery.py`
⚠️ THE FIXTURE IS THE CARD, AND IT IS PART 4. What this guard has to do is tell
a WEDGED unit from a COLD PACK AT ITS FLOOR, and those two are IDENTICAL in the
power domain - the same shares, the same measured watts, the same meter, the
same `saturated-freeze`. Measured on admin/goodwe's two-master rig over a
120-cycle run: the two arms agree on every one of 120 cycles in (grid, site
target, per-unit share, per-unit measured power), to the last emitted watt. If
this file's two fixtures ever stop differing, the check has stopped being
graded on the only thing that separates the two cases.
⚠️ AND PART 4b IS THE OTHER HALF OF THE SAME LESSON, ONE LEVEL UP. Part 4 shows
that no threshold on the power domain can separate the two cases; part 4b shows
that a check which only ever runs at the SHIPPED value of a threshold cannot
separate "this call site reads the operator's config" from "this call site has a
number typed into it". Both are the same defect - a fixture whose values cannot
reach the failure it names - and the second one hid two literals from all 23
suites in this repository.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ. That is TEST-15, and
it has already turned one live mutation into a false survivor. Read MUTATION.md
before you report a survivor.
"""
import asyncio
import io
import re
import sys
from datetime import datetime, timedelta, timezone
from named_absence import given, present
from app import messages
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent,
short_by,
)
from app.inverter import LINK_STALE_S
from app.main import Controller
from app.peakguard import discharge_permitted
from app.store import unit_key
fails = []
T0 = datetime(2026, 9, 2, 12, 0, 0, tzinfo=timezone.utc)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def U(name, share, meas, why=None):
return UnitDelivery(name, share, meas, why)
# ===========================================================================
print("=" * 72)
print("1. the quantity, and the reduction that makes it readable")
print("=" * 72)
check("short_by is the watts of a DISCHARGE share that did not arrive",
short_by(2000, 0) == 2000.0 and short_by(2000, 1400) == 600.0)
check("...delivering the share, or more than it, is not a shortfall - both "
"answer None rather than a number a caller would have to sign-test",
short_by(2000, 2000) is None and short_by(2000, 2600) is None)
check("...a CHARGE share answers None. There is no configured charge ceiling "
"in this add-on, so a pack in constant-voltage taper cannot be explained "
"and arming it would fire on every maintenance charge",
short_by(-2000, 0) is None and short_by(-2000, -100) is None)
# ⚠️ THE CASE THAT ACTUALLY GRADES THE `share <= 0` GUARD, AND THE MUTATION
# BATTERY IS WHAT FOUND IT MISSING. Relaxing that guard to `share == 0` left the
# two rows above GREEN - for a charge share that is UNDER-delivered,
# `share - measured` is negative and the `gap > 0` test answers None anyway, so
# the mutant is masked. The one input where the two differ is a unit moving MORE
# than its charge share: -2000 W told, -3000 W measured, `share - measured` =
# +1000 W, which the mutant reports as a SHORTFALL. It is not one - it is
# something else driving that pack, which is SAFETY-30's and SAFETY-31's
# question - and reading it as "short of its share" would name the wrong fault.
check("...and a unit moving MORE than its charge share is not short of it "
"either. This is the input that separates `share <= 0` from `share == 0`; "
"the two rows above do not, because the `gap > 0` test masks them",
short_by(-2000, -3000) is None and short_by(-2000, -2600) is None)
check("...a missing reading is None, never 'the whole share'. Zero would be "
"this check inventing a fault out of an absence",
short_by(2000, None) is None and short_by(None, 0) is None)
print()
print(" `account()` - the ONLY thing that separates a wedged unit from a")
print(" cold pack, because nothing in the power domain does")
check("a pack at its own cut-out accounts for its shortfall",
account(live=True, parked=False, soc=10.0, bms_floor=10.0) is not None)
check("...and a pack above it does not",
account(live=True, parked=False, soc=10.1, bms_floor=10.0) is None)
check("...the boundary is `<=`, the same one discharge_permitted() draws, "
"BECAUSE IT IS discharge_permitted() - not a transcription of it",
(account(live=True, parked=False, soc=10.0, bms_floor=10.0) is None)
== (discharge_permitted(10.0, 10.0) is None)
and (account(live=True, parked=False, soc=10.1, bms_floor=10.0) is None)
== (discharge_permitted(10.1, 10.0) is None))
check("a unit the allocator has already dropped accounts for itself - firing "
"inside a state already being handled is the SAFETY-15/19 failure",
account(live=False, parked=False, soc=50.0, bms_floor=10.0) is not None)
check("a unit parked at 0 W by a sign trip accounts for itself: not "
"delivering is compliance",
account(live=True, parked=True, soc=50.0, bms_floor=10.0) is not None)
check("EVERY UNKNOWN FAILS TOWARD SILENCE - an unreadable SoC and an unstated "
"floor both account for the shortfall, which is the OPPOSITE direction "
"to discharge_permitted()'s own caller and is the right one here: an "
"alarm nobody can justify is the crying-wolf failure this file exists "
"to avoid",
account(live=True, parked=False, soc=None, bms_floor=10.0) is not None
and account(live=True, parked=False, soc=50.0, bms_floor=None) is not None)
print(f" e.g. {account(live=True, parked=False, soc=9.0, bms_floor=10.0)!r}")
print()
print(" the PROPER-SUBSET rule - what keeps a site-wide cause out")
_wedged = delinquent([U("a", 2000, 2000), U("b", 2000, 0)], 500)
check("one unit short and unexplained beside a sibling that is not: NAMED",
list(_wedged) == ["b"])
check("...and the sentence names the unit, the two watt figures and the peer, "
"because 'B is not delivering' and 'B is not delivering while A is' are "
"different facts and only the second one is this check's conclusion",
REASON_CODE in _wedged["b"] and "+2000 W" in _wedged["b"]
and "+0 W" in _wedged["b"] and "a" in _wedged["b"])
print(f" {_wedged['b']}")
_cold = delinquent([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500)
check("THE CARD: the same numbers, with the shortfall ACCOUNTED FOR, name "
"nobody. This pair of assertions is the whole guard",
_cold == {})
_both = delinquent([U("a", 2000, 0), U("b", 2000, 0)], 500)
check("EVERY asked unit short and unexplained: NOBODY is named. That is a "
"site condition - a derate, a curtailment, a plant that cannot - and "
"this check has no instrument that tells it from every box being wedged "
"at once. Reporting it would be the saturation-shaped alarm again",
_both == {})
_three = delinquent([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500)
check("...but a proper subset of THREE names both of its members",
sorted(_three) == ["b", "c"])
_mixed = delinquent([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500)
check("a wedged unit beside a pack that is legitimately empty still names the "
"wedged one: `asked` is the denominator, not `short`",
list(_mixed) == ["b"])
check("a shortfall AT the threshold is not one - the bound is strict, so a "
"unit exactly on the gap the control law calls ordinary tracking is not "
"reported",
delinquent([U("a", 2000, 2000), U("b", 2000, 1500)], 500) == {}
and list(delinquent([U("a", 2000, 2000), U("b", 2000, 1499)], 500)) == ["b"])
check("a unit given no share is not asked and cannot be named, and it is not "
"a peer either - with only one unit asked there is nothing to compare",
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
print()
print(" SAFETY-43: THE RELATIVE ARM - short OF ITS OWN ASK, not just short")
# ⚠️ `share_frac=None` IS SAFETY-42's RULE EXACTLY, and every check in this part
# above this line is run under BOTH rules below to show the card took nothing
# away. What it ADDS is one thing: a unit can now be named for delivering almost
# none of a SMALL share. OPT-09's weights make small shares ordinary - a
# low-headroom unit is weighted down to 125 W of a 1630 W site target - so
# before this card a wedged unit could be 100 % delinquent and 125 W short, and
# 125 W is under a 500 W threshold. test_packshare.py part 8b measures that end
# to end on the real loop; what is graded here is the predicate.
_ABS = None # SAFETY-42's rule
_tiny = [U("a", 1505, 1505), U("b", 125, 0)]
check("A UNIT DELIVERING 0 OF 125 W IS NAMED, AND UNDER THE OLD RULE IT WAS "
"NOT. Same three units, same `min_w`, one input changed: the absolute arm "
"alone answers {} because 125 W < 500 W, and the shipped rule names it "
"because 100 % of its ask is not arriving",
delinquent(_tiny, 500, _ABS) == {}
and list(delinquent(_tiny, 500)) == ["b"])
# ⚠️ `.get`, NOT `["b"]`, AND THAT IS NOT STYLE. A bare subscript here raises
# KeyError on any tree where the relative arm is broken or absent, which ABORTS
# THE FILE at part 1 and leaves parts 2-8 UNGRADED. The direction is safe - an
# abort is a red, and MUTATION.md's defect can only fabricate a green - but the
# recorded coverage of a mutation run then reads larger than it was, which is
# the shape M11's entry in test_packshare.py part 11 already discloses. Found
# in review of this card, on M12 and M13.
_tiny_said = delinquent(_tiny, 500).get("b", "(not named - see the check above)")
print(f" {_tiny_said}")
check("...and the sentence carries the PROPORTION as well as the watts, because "
"'125 W is not arriving' reads like a rounding error and '125 W (100 % of "
"it)' is the fault",
"125 W of its share (100 % of it) is not arriving" in _tiny_said)
check("THE ACCOUNT GATE IS UNTOUCHED BY IT - the same 0 of 125 W with the "
"shortfall EXPLAINED names nobody. A relative criterion that could talk "
"its way past `account()` would report every pack sitting at its own "
"cut-out on a small share, which is the crying-wolf failure this module "
"exists to avoid",
delinquent([U("a", 1505, 1505), U("b", 125, 0, "its pack is empty")],
500) == {})
check("...and so is the PROPER-SUBSET rule: both units delivering none of a "
"small share is a SITE condition and is still nobody's fault to name",
delinquent([U("a", 125, 0), U("b", 125, 0)], 500) == {})
check("...and the relative bound is STRICT, like the absolute one. A gap of "
"EXACTLY the fraction of the share is not over it; one watt more is",
delinquent([U("a", 2000, 2000),
U("b", 1000, 1000 * (1.0 - DELIVERY_SHARE_FRACTION))],
5000) == {}
and list(delinquent([U("a", 2000, 2000),
U("b", 1000,
1000 * (1.0 - DELIVERY_SHARE_FRACTION) - 1)],
5000)) == ["b"])
_both_rules = [(delinquent(u, w, _ABS), delinquent(u, w)) for u, w in (
([U("a", 2000, 2000), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500),
([U("a", 2000, 0), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500),
([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 1500)], 500),
([U("a", 0, 0), U("b", 2000, 0)], 500),
)]
check(f"⚠️ AND IT TOOK NOTHING AWAY: all {len(_both_rules)} fixtures above this "
f"block answer IDENTICALLY under SAFETY-42's rule and the shipped one. "
f"The relative arm is an `or`, so it can only ever ADD a naming - and on "
f"every case this part already graded it adds none. That is the "
f"difference between adding a second way to be named and lowering "
f"`min_w`, which would have moved all seven",
all(a == b for a, b in _both_rules))
print()
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
_n1 = [delinquent([U("a", s, m)], 500)
for s in (0, 500, 2000, 5000, -2000, None)
for m in (0, 250, 2000, -2000, None)]
check(f"every one of {len(_n1)} single-unit rosters over the whole "
f"(share, measured) grid answers {{}}", all(r == {} for r in _n1))
check("...including a single unit that IS short and unexplained, which is the "
"case a caller might expect to fire. It must not: with no sibling, "
"'this box cannot' and 'this plant cannot' are the same observation",
delinquent([U("a", 2000, 0)], 500) == {})
# ===========================================================================
print()
print("=" * 72)
print("2. CFG-03: config.yaml is the source of truth and there is ONE copy")
print("=" * 72)
_CFG_TEXT = io.open("config.yaml", encoding="utf-8").read()
def read_option(key, block="options"):
"""The value of `key` inside one top-level block of config.yaml, as text.
⚠️ READ OUT OF THE FILE, NOT TRANSCRIBED. CFG-03's whole complaint is that a
default lives in more than one place; a check that hard-coded the number
here would be a THIRD copy, and the one that never goes red.
⚠️ ANCHORED AT EXACTLY TWO SPACES, WHICH IS CFG-04's LESSON AND NOT A STYLE
CHOICE. a leading-whitespace pattern matches a PER-INVERTER `max_w` nested under
`inverters:` as readily as the site one, first hit wins, and a reader that
quietly answers inverter #1's number is the defect `test_inverter.py`'s
config.yaml read-closure audit exists to hunt. This function is in that
audit's table by signature - first argument `key` - so it is fed a
two-inverter config and graded on what it answers.
"""
body = _CFG_TEXT.split(f"\n{block}:", 1)[1]
if block == "options":
body = body.split("\nschema:", 1)[0]
m = re.search(rf"^ {re.escape(key)}:\s*(\S+)\s*$", body, re.M)
return m.group(1) if m else None
check("`delivery_hold_s` is in config.yaml's options: block",
read_option("delivery_hold_s") is not None)
check("...and DELIVERY_OPTION_DEFAULTS agrees with it, so the running add-on - "
"which cannot read config.yaml, the Dockerfile does not ship it - uses "
"the number an operator sees on the Configuration page",
int(read_option("delivery_hold_s"))
== int(DELIVERY_OPTION_DEFAULTS["delivery_hold_s"]))
check("...and it has a schema: entry, so the Supervisor refuses a value "
"outside the range the derivation answers for",
read_option("delivery_hold_s", "schema") is not None)
_schema = read_option("delivery_hold_s", "schema")
_lo, _hi = (int(x) for x in re.match(r"int\((\d+),(\d+)\)", _schema).groups())
check(f"the schema floor is inverter.LINK_STALE_S = 30 s ({_schema}): below "
f"the firmware's own veto window a unit not executing its share is a "
f"LINK fault the add-on already answers by dropping it from the split",
_lo == 30)
check("the schema ceiling is the 900 s quarter that bills the capacity tariff "
"- a site running short of one unit's share must be reported well "
"inside one", _hi == 900)
check("the shipped default is inside its own schema",
_lo <= int(DELIVERY_OPTION_DEFAULTS["delivery_hold_s"]) <= _hi)
check("THERE IS NO SECOND KEY. The watts half is `Tuning.saturation_w`, read "
"at runtime, so it cannot drift from the number the control law itself "
"calls 'not following' - CFG-03 already tracks three places a default "
"has to be kept in step and this card does not earn a fourth",
list(DELIVERY_OPTION_DEFAULTS) == ["delivery_hold_s"]
and not re.search(r"^\s+delivery_w:", _CFG_TEXT, re.M))
print(f" delivery_hold_s: {DELIVERY_OPTION_DEFAULTS['delivery_hold_s']} s, "
f"schema {_schema}; watts = Tuning.saturation_w = {Tuning().saturation_w:.0f} W")
# ===========================================================================
print()
print("=" * 72)
print("3. the window and the latch")
print("=" * 72)
def drive(seconds, cfg=None, sentence="short", step=5):
wd = DeliveryWatchdog(cfg or DeliveryConfig(hold_s=60.0))
at = None
for t in range(0, int(seconds) + 1, step):
if wd.evaluate(sentence, T0 + timedelta(seconds=t)) and at is None:
at = t
return wd, at
_wd, _at = drive(300)
check("a shortfall held past the window reports", _wd.tripped)
check("...at the window, not before", _at == 60)
check("...and the reason carries the sentence AND how long it stood",
"short" in _wd.reason and "held for" in _wd.reason and "60 s" in _wd.reason)
print(f" {_wd.reason!r}")
_wd2, _at2 = drive(55)
check("a shortfall shorter than the window reports nothing",
not _wd2.tripped and _at2 is None)
_wd3 = DeliveryWatchdog(DeliveryConfig(hold_s=60.0))
for _t in range(0, 300, 5):
_wd3.evaluate("short" if (_t // 5) % 2 == 0 else None,
T0 + timedelta(seconds=_t))
check("an INTERMITTENT shortfall never accumulates: any clean cycle clears the "
"window, so this needs a CONTINUOUS episode the way SAFETY-02 and "
"SAFETY-31 do", not _wd3.tripped and _wd3.since is None)
_wd4, _ = drive(300)
_before = _wd4.reason
_wd4.evaluate(None, T0 + timedelta(seconds=999))
check("it LATCHES - and not for SAFETY-02's reason. Nothing here removes the "
"evidence, because nothing here changes what is commanded; it latches so "
"that a unit which stopped delivering for minutes and then resumed "
"cannot erase its own record",
_wd4.tripped and _wd4.reason == _before)
_wd4.reset()
check("...and reset() is the operator's press, which clears everything",
not _wd4.tripped and _wd4.reason == "" and _wd4.since is None)
_wd5 = DeliveryWatchdog(DeliveryConfig(hold_s=60.0))
_wd5.evaluate("short", T0)
_wd5.evaluate("short", T0 + timedelta(seconds=5))
_since = _wd5.since
_wd5.arm(DeliveryConfig(hold_s=120.0))
check("arm() is CONFIGURATION, NOT STATE: it does not touch the window, the "
"latch or the reason. A detector that cleared its own window every cycle "
"when it was told what it was measuring would never accumulate a hold - "
"that is SignWatchdog.arm()'s lesson, found the expensive way",
_wd5.since == _since and _wd5.cfg.hold_s == 120.0 and not _wd5.tripped)
check("held_s takes the caller's clock rather than reading one",
_wd5.held_s(T0 + timedelta(seconds=65)) == 65.0)
_wd6 = DeliveryWatchdog(DeliveryConfig(hold_s=60.0), tripped=True, reason="from disk")
check("a latch RESTORED from the store comes up tripped, with its reason",
_wd6.tripped and _wd6.reason == "from disk")
check("...and a reason with no latch behind it is dropped, not carried",
DeliveryWatchdog(tripped=False, reason="stale").reason == "")
# ===========================================================================
print()
print("=" * 72)
print("4. END TO END, AND THIS IS THE CARD: a wedged unit and a cold pack at")
print(" its floor are IDENTICAL in the power domain. Only the SoC differs.")
print("=" * 72)
# ⚠️ THE PLANT IS MODELLED AND SAYS SO. `_DeliveryHass` is two inverters that
# obey INSTANTLY - no ramp, no lag - and one meter that is `load - sum(measured)`.
# That is deliberately not a claim about transients: the worst legitimate
# episode a real ramp and lag can produce is measured on admin/goodwe's
# two-master rig and recorded in DECISIONS/SAFETY-42-unit-delivery.md. What this
# fixture is for is the DISCRIMINATION - which of two identical power traces is
# reported - and for that an instant plant is the honest model, because it
# removes the one mechanism that could make an obedient unit look absent.
#
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (NOTES.md §1.1b). Every
# two-inverter figure in this file, and every one in the decision record, is
# TWO COPIES OF INVERTER A - `plantsim.INVERTER_B`, `measured=False`.
_LOAD_W = 3000.0
_FLOOR_PCT = 10
class _DeliveryHass:
"""Two inverters, one meter, and unit B that produces nothing.
`b_soc` is the ONLY thing that differs between the two arms of the
experiment: 50 % is a wedged unit with a healthy pack, `_FLOOR_PCT` is a
pack sitting exactly on its own cut-out. Every watt on every wire is the
same in both.
"""
def __init__(self, load_w=_LOAD_W, b_soc=50.0, b_frac=0.0):
self.load_w = float(load_w)
self.b_soc = float(b_soc)
# SAFETY-43. What fraction of its command B actually delivers. 0.0 is a
# WEDGED unit and is every arm in this file but part 4b(a) - see there
# for why that one arm needs a unit that is short WITHOUT being 100 %
# short.
self.b_frac = float(b_frac)
self.writes = []
self.calls = []
self.ctrl = None
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
async def limits(self, entity):
return None
def measured(self):
if self.ctrl is None:
return [0.0, 0.0]
a = self.ctrl.inverters[0]
# A obeys. B produces NOTHING, whatever it is told - and whether that is
# a wedged power stage or an empty pack is not a question its power
# register can answer.
b = self.ctrl.inverters[1]
b_cmd = float((b.last_write if b.last_write is not None else b.target)
or 0.0)
return [float((a.last_write if a.last_write is not None else a.target)
or 0.0), self.b_frac * b_cmd]
async def number(self, entity, invert=False):
m = self.measured()
return {"sensor.grid": self.load_w - sum(m),
"sensor.soc_a": 50.0, "sensor.soc_b": self.b_soc,
"sensor.batt_a": m[0], "sensor.batt_b": m[1]}.get(entity)
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
return True
def _roster(floors=(_FLOOR_PCT, _FLOOR_PCT)):
"""The two-unit roster. `floors` is (A's own cut-out, B's own cut-out).
⚠️ IT IS AN ARGUMENT RATHER THAN `_FLOOR_PCT` TWICE, AND THAT IS HALF OF
PART 4b. Every fixture in this file used to state 10 %, which is also the
number config.yaml's own `inverters:` example states, so `bms_floor=10.0`
written at main.py's call site in place of `inv.bms_soc_floor` answered
identically in every check here and in the other 22 files. A default that
happens to agree with the fixture is not a pin.
"""
return [{"name": n, "setpoint_entity": f"number.sp_{t}",
"soc_entity": f"sensor.soc_{t}", "batt_entity": f"sensor.batt_{t}",
"max_w": 2000, "bms_soc_floor": f}
for (n, t), f in zip((("inverter-a", "a"), ("inverter-b", "b")),
floors)]
def _ctrl(hass, store=None, *, floors=(_FLOOR_PCT, _FLOOR_PCT), **over):
"""A two-inverter Controller, with the NEIGHBOURING guards given long windows.
⚠️ THE LONG `circulation_hold_s` AND SIGN HOLDS ARE ISOLATION, NOT A CLAIM
THAT THOSE GUARDS ARE SILENT HERE - part 7 asserts what each of them
actually concludes on this same fixture, which is the honest version of the
claim. What must not happen is the SITE being stopped by another guard while
this section is trying to grade an attribution, because then every check
below would be measuring the wrong thing.
"""
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": _roster(floors), "gain": 0.6, "slew_w": 1000,
# ⚠️ 1 s, NOT THE SHIPPED 95. This window is WALL CLOCK - the
# detector takes the caller's `now`, and `_drive()` runs fourteen
# real cycles 0.2 s apart, so a shipped-length hold would make every
# check below pass for the wrong reason: nothing would have tripped
# yet and "no alarm" would read the same as "no fault". The SHIPPED
# value is graded in part 2 against config.yaml, which is where a
# number belongs; what is graded here is the attribution.
"delivery_hold_s": 1,
"unit_sign_disagree_s": 3600, "site_sign_disagree_s": 3600,
"circulation_hold_s": 3600, "auto_start": True}
opts.update(over)
c = Controller(opts, hass, store if store is not None else _MemStore(),
_P(), stop=asyncio.Event())
for inv in c.inverters:
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
c.auto = True
hass.ctrl = c
return c
class _MemStore:
"""A Store that keeps its dict across "restarts", like the real file does.
Seeded from `store.DEFAULTS` for test_watchdog.py's reason: a fake that
hand-listed keys answers None where the real store answers a default, and a
check reading "this latch was never written" then cannot tell an absent key
from a False one.
"""
def __init__(self):
from app.store import DEFAULTS as _SDEF
self.data = dict(_SDEF)
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
class _P:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
async def _drive(c, n=14, dt=0.2, watch=None):
seen = []
for _ in range(n):
await c.read_inputs()
await c.cycle()
if watch is not None:
seen.append(watch(c))
await asyncio.sleep(dt)
return seen
def _sample(c):
return dict(
grid=c.grid, target=round(c.target, 6),
shares=tuple(round(float(i.last_write if i.last_write is not None
else i.target), 6) for i in c.inverters),
meas=tuple(None if i.batt is None else round(float(i.batt), 6)
for i in c.inverters),
soc=tuple(i.soc for i in c.inverters),
named=tuple(sorted(i.name for i in c.inverters
if i.delivery_wd.tripped)),
reason=c.reason)
async def _arm(b_soc):
hass = _DeliveryHass(b_soc=b_soc)
c = _ctrl(hass)
return c, hass, await _drive(c, watch=_sample)
_cw, _hw, _sw = asyncio.run(_arm(50.0)) # WEDGED: healthy pack, no output
_cf, _hf, _sf = asyncio.run(_arm(float(_FLOOR_PCT))) # COLD: pack at its floor
def _power_domain(rows):
return [(r["grid"], r["target"], r["shares"], r["meas"]) for r in rows]
_pw, _pf = _power_domain(_sw), _power_domain(_sf)
# ⚠️ OPT-09 CHANGED WHAT THIS PAIR OF ARMS LOOKS LIKE, AND THE CARD'S FINDING
# SURVIVES IT INTACT. UNTIL OPT-09 the two traces were byte-identical in every
# power number - the cold pack was ASKED for the same +2000 W the wedged one was
# asked for, and both produced 0 W - and that identity was this file's headline.
# OPT-09 closes a pack's `hi` at 0 W once its own state of charge is at or under
# its own `bms_soc_floor`, so the cold arm's B is now commanded 0 W and the two
# arms' SHARES differ. ⚠️ THAT IS A CHANGE IN WHAT THE SITE ASKS FOR, NOT IN
# WHAT ANY DETECTOR CAN SEE, and the card's argument is about the latter: the
# proposed discriminator reads SATURATION-PERSISTENCE and |GRID ERROR|, and on
# BOTH arms the loop is saturation-frozen on every cycle while ~1000 W stands at
# the meter. No (N, X) separates them, exactly as measured on the rig - so the
# claim is now stated on the two quantities the predicate actually reads rather
# than on the whole trace, which is the stronger form of the same sentence.
_ERR_W = 900.0
_frozen = [[r["reason"] for r in rows] for rows in (_sw, _sf)]
_settled = [[r for r in rows if abs(r["grid"]) > _ERR_W] for rows in (_sw, _sf)]
print(f" the two quantities the card's predicate reads, last 8 cycles: "
f"wedged grid {[round(r['grid']) for r in _sw[-8:]]}, "
f"cold grid {[round(r['grid']) for r in _sf[-8:]]}")
check(f"THE TWO ARMS ARE INSEPARABLE IN THE TWO QUANTITIES THE CARD'S PROPOSED "
f"DISCRIMINATOR READS. On both, the loop reports the SAME reason on the "
f"last {len(_sw[-8:])} cycles and |grid error| stands over "
f"{_ERR_W:.0f} W on every one of them. No threshold on saturation "
f"persistence and no threshold on grid error, at any N and any X, can "
f"separate a wedged unit from a pack at its floor",
[r["reason"] for r in _sw[-8:]] == [r["reason"] for r in _sf[-8:]]
and len(_settled[0]) >= 8 and len(_settled[1]) >= 8)
check("...and the SHARES now differ, which is OPT-09 and not a weakening of the "
"line above: the cold pack is commanded 0 W because its own SoC closed "
"its own envelope, while the wedged pack - whose SoC is healthy - is "
"still asked for its full share and still delivers nothing. The two "
"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)
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 "
f"{tuple(round(v) for v in _sw[-1]['shares'])}, measured "
f"{tuple(round(v) for v in _sw[-1]['meas'])}, reason {_sw[-1]['reason']!r}")
print(f" SoC: wedged arm {_sw[-1]['soc']} cold arm {_sf[-1]['soc']}")
check("AC 1: the WEDGED unit is NAMED, and only it",
_sw[-1]["named"] == ("inverter-b",))
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT",
_sf[-1]["named"] == ())
print(f" {_cw.inverters[1].delivery_wd.reason}")
# ⚠️ THE VACUOUS-PASS GUARD, AND IT IS NOT DECORATION. MUTATION.md's third
# failure mode is a check whose data cannot reach the failure it names: "the
# cold arm named nobody" reads exactly the same against a fixture where nothing
# was short, nothing was asked, or the loop never ran.
#
# ⚠️ AND OPT-09 MOVED WHICH OF THOSE IS TRUE, WHICH IS WORTH MORE THAN THE
# CHECK IT REPLACES. Until OPT-09 the cold pack WAS asked for its full share,
# was short of it, and was turned away by `account()` alone - the account was the
# only thing standing between an empty battery and an alarm. It is not any more:
# the pack's own envelope is closed, so it is never asked, `short_by()` answers
# None on a zero share, and AC 2 is delivered STRUCTURALLY - by the site not
# asking - rather than by a discriminator applied after the fact.
#
# BOTH HALVES ARE ASSERTED, because the second is the one that survives OPT-09
# being reverted or a pack whose numbers are absent: `account()` STILL turns this
# pack away when the ask IS present, and that is asked of it directly here on
# this arm's own measured SoC and configured cut-out. Parts 2 and 3 grade the
# predicate over its whole input space; this line pins it to THIS fixture's
# numbers so the end-to-end arm cannot pass for a reason unrelated to the pack.
_cold_b = _cf.inverters[1]
check("...and the cold arm reaches AC 2 the way OPT-09 makes it reach it: B's "
"own state of charge closed B's own envelope, so B is commanded 0 W and "
"`short_by()` has no discharge share to be short of. The ask never "
"arrives, which is a better answer than an alarm suppressed after it does",
float(_cold_b.standing_w()) == 0.0
and short_by(_cold_b.standing_w(), _cold_b.batt) is None)
check("...and `account()` STILL turns this pack away if the ask does arrive - "
"asked directly, on this arm's own measured SoC and its own configured "
"cut-out. That is the half that must not depend on OPT-09: an unreadable "
"SoC or an unstated floor is a case OPT-09 deliberately does NOT close on, "
"and the account is what keeps those quiet",
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",
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)
# ⚠️ 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
# SITE's `peak_soc_floor`, and handing THAT to `account()` would look correct
# and would be wrong: `peak_soc_floor` bounds a money-saving discharge only
# (SAFETY-25), ordinary self-consumption discharges straight past it, and a
# wedged unit sitting anywhere between the two floors would be EXPLAINED AWAY.
# So this arm puts B exactly there - 15 %, above its own 10 % cut-out and below
# a 20 % `peak_soc_floor` - and it must still be named.
async def _between_floors():
hass = _DeliveryHass(b_soc=15.0)
c = _ctrl(hass, peak_soc_floor=20)
await _drive(c)
return c
_cbf = asyncio.run(_between_floors())
check("a wedged unit BETWEEN its own cut-out and the site's peak_soc_floor is "
"still named: the floor this check asks about is the pack's own, not the "
"peak guard's, and swapping them would hide the fault in a band an "
"ordinary install has 10 points of",
_cbf.inverters[1].delivery_wd.tripped
and _cbf.inverters[1].soc == 15.0
and float(_cbf.o.get("peak_soc_floor")) == 20.0)
# ===========================================================================
print()
print("=" * 72)
print("4b. THE NUMBERS THIS CHECK READS ARE THE CONFIGURED ONES, AND THAT IS")
print(" GRADED AT A CONFIG THAT IS NOT THE SHIPPED ONE")
print("=" * 72)
# ⚠️ WHY THIS PART EXISTS, AND IT IS NOT A MUTATION SCORE. Two of the values
# main.py hands this check are OPTIONS, and the paragraph above
# `delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in
# config.yaml both make a PROMISE about them in shipped, operator-readable
# prose: there is no fourth copy of a default to keep in step, and no way for
# the two to drift, because an operator who raises `saturation_w` has told the
# loop that a bigger gap is ordinary tracking and this check moves with them BY
# CONSTRUCTION. A literal written at the call site breaks exactly that promise,
# silently, and the promise is why the design was chosen over a `delivery_w`
# key.
#
# ⚠️ AND EVERY FIXTURE ABOVE IS BLIND TO IT, WHICH IS THIS REPOSITORY'S
# RECURRING DEFECT (MUTATION.md's third failure mode): a fixture whose values
# cannot reach the failure it names. Part 4's gaps are all far above BOTH the
# shipped `saturation_w` and any smaller literal, and every `bms_soc_floor` in
# this file was 10 - the number config.yaml's own example states. So
#
# self.tuning.saturation_w -> 250.0 (-19 B)
# bms_floor=inv.bms_soc_floor -> 10.0 (-13 B)
#
# were BOTH survivors of the whole 23-file suite, not of this file alone. The
# two blocks below are what kills them, and they kill not just those two
# literals but EVERY literal: each pair of arms is answered one way at one
# configured value and the other way at another, on the same fixture, so no
# single constant can satisfy both rows.
print()
print(" (a) THE WATTS - `saturation_w`, raised to a value inside its own")
print(" schema. Same load, same wedged unit, two configured thresholds.")
# 1800 is inside the shipped schema `int(100,2000)` and is not the shipped
# default - both asserted below rather than asserted here in a comment. The
# load is what puts B's shortfall BETWEEN the two: above the shipped threshold,
# below the raised one. The gap itself is printed by the run that produces it
# and is never written down here; what is asserted is the ORDERING.
#
# ⚠️ AND B IS DERATED HERE RATHER THAN WEDGED, WHICH SAFETY-43 MADE NECESSARY.
# `delinquent()` now names a unit whose gap exceeds `min_w` OR a fraction of its
# own share, so a unit delivering NONE of its ask is named at every `min_w`
# there is - which is the whole point of that card, and which would make the
# raised arm below fire for a reason that has nothing to do with the watts. A
# unit delivering HALF of what it is asked for is short in absolute watts and
# comfortably inside the fraction, so it is the fixture that still isolates
# `saturation_w`. `_SAT_B_FRAC` is asserted below to be inside the relative
# arm's silence, so a future change to the fraction turns this red rather than
# quietly turning the pin vacuous.
_SAT_RAISED = 1800
_SAT_LOAD_W = 1600.0
_SAT_B_FRAC = 0.5
_SAT_SHIPPED = int(read_option("saturation_w"))
_sat_lo, _sat_hi = (int(x) for x in re.match(
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac)
c = _ctrl(hass, saturation_w=sat)
return c, await _drive(c, watch=_sample)
_c_ship, _r_ship = asyncio.run(_at_saturation_w(_SAT_SHIPPED))
_c_up, _r_up = asyncio.run(_at_saturation_w(_SAT_RAISED))
_b_ship, _b_up = _c_ship.inverters[1], _c_up.inverters[1]
_gap_ship = given(short_by(_b_ship.standing_w(), _b_ship.batt), "B not short")
_gap_up = given(short_by(_b_up.standing_w(), _b_up.batt), "B not short")
check(f"the raised threshold is a config an operator can actually enter: "
f"{_SAT_RAISED} W is inside the shipped schema "
f"{read_option('saturation_w', 'schema')}, and it is NOT the shipped "
f"default ({_SAT_SHIPPED} W) - both read out of config.yaml, not typed "
f"here",
_sat_lo <= _SAT_RAISED <= _sat_hi and _SAT_RAISED != _SAT_SHIPPED)
check("...and the controller really is running on it - `Tuning.saturation_w` "
"carries the option through, so this is a changed CONFIG and not a "
"changed test",
_c_up.tuning.saturation_w == float(_SAT_RAISED)
and _c_ship.tuning.saturation_w == float(_SAT_SHIPPED))
# ⚠️ THE VACUOUS-PASS GUARD AGAIN, and here it is the whole load. "the raised
# arm named nobody" reads identically against a fixture where B was never
# asked, was not short, or was explained away. So the raised arm is asserted to
# have reached the threshold comparison and to have been turned away by NOTHING
# BUT the number.
check("the raised arm REACHED the comparison: B was asked for a discharge "
"share, is short of it, is live, is not parked, and NOTHING accounts for "
"it - the only thing standing between this unit and an alarm is the "
"configured watts",
float(_b_up.standing_w()) > 0 and present(_gap_up)
and not _b_up.sign_wd.tripped
and account(live=True, parked=False, soc=_b_up.soc,
bms_floor=_b_up.bms_soc_floor) is None)
check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
"which is what leaves the watts as the only moving part. B delivers half "
"of what it is asked for, so its gap is half its share and that is "
"inside `DELIVERY_SHARE_FRACTION`. ⚠️ A FIXTURE WHERE THE RELATIVE ARM "
"FIRED WOULD NAME B AT EVERY `saturation_w` AND THIS PIN WOULD BE "
"VACUOUS, so it is asserted rather than assumed",
_gap_up / float(_b_up.standing_w()) <= DELIVERY_SHARE_FRACTION
and _gap_ship / float(_b_ship.standing_w()) <= DELIVERY_SHARE_FRACTION
and delinquent([U("a", 1000, 1000),
U("b", float(_b_up.standing_w()), _b_up.batt)],
_SAT_RAISED) == {})
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
"thresholds, which is the one place the two answers can differ. Part 4's "
"fixtures are all above both, which is exactly why they cannot see this",
present(_gap_up) and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED))
check("AT THE SHIPPED THRESHOLD THE UNIT IS NAMED",
_r_ship[-1]["named"] == ("inverter-b",))
check("AT THE RAISED THRESHOLD, ON THE SAME FIXTURE, IT IS NOT - the operator "
"has told the loop that a gap this size is ordinary tracking, and this "
"check moved with them. THIS PAIR IS THE PIN: a literal at the call site "
"is one number, and no one number answers both of these rows",
_r_up[-1]["named"] == ())
print(f" shipped saturation_w {_SAT_SHIPPED} W: B short by "
f"{_gap_ship:.0f} W -> named {_r_ship[-1]['named']}")
print(f" raised saturation_w {_SAT_RAISED} W: B short by "
f"{_gap_up:.0f} W -> named {_r_up[-1]['named']}")
# And the same two answers out of the pure function, on the raised arm's OWN
# two watt figures - so the flip is a property of `delinquent()`'s threshold
# and not of anything the controller did differently between the two runs.
_pair = [U("inverter-a", float(_c_up.inverters[0].standing_w()),
_c_up.inverters[0].batt),
U("inverter-b", float(_b_up.standing_w()), _b_up.batt)]
check("...and `delinquent()` says the same thing about that one pair of watt "
"figures: named at the shipped number, silent at the raised one. The "
"threshold is the only input that moved",
list(delinquent(_pair, _SAT_SHIPPED)) == ["inverter-b"]
and delinquent(_pair, _SAT_RAISED) == {})
print()
print(" (b) THE PERCENT - each unit's OWN `bms_soc_floor`, at two cut-outs")
print(" that BRACKET the shipped example from both sides.")
# ⚠️ WHY TWO ARMS AND NOT ONE. A single arm at a deeper cut-out kills `10.0`
# but not `50.0`; a single arm at a shallower one kills `10.0` but not `0.0`.
# One arm on each side of the shipped example leaves no constant at all: the
# deep arm must stay SILENT (its pack is genuinely below its own cut-out) and
# the shallow arm must SPEAK (its pack is genuinely above its own cut-out), and
# a literal that satisfies the first cannot satisfy the second.
_FLOOR_DEEP, _DEEP_SOC = 25, 15.0 # pack BELOW its own cut-out: cold
_FLOOR_SHALLOW, _SHAL_SOC = 5, 8.0 # pack ABOVE its own cut-out: wedged
_floor_lo, _floor_hi = (int(x) for x in re.match(
r"int\((\d+),(\d+)\)", read_option("bms_soc_floor", "schema")).groups())
async def _at_floor(b_floor, b_soc, peak):
hass = _DeliveryHass(b_soc=b_soc)
# ⚠️ `peak_soc_floor` IS RAISED WITH THE DEEP ARM SO THE FIXTURE STAYS A
# CONFIG THE ADD-ON WOULD START ON: validate.py rule 8 refuses a
# `peak_soc_floor` at or below ANY unit's `bms_soc_floor`. A fixture that
# only a refused config can produce would grade a state no install reaches.
c = _ctrl(hass, floors=(_FLOOR_PCT, b_floor), peak_soc_floor=peak)
return c, await _drive(c, watch=_sample)
_c_deep, _r_deep = asyncio.run(_at_floor(_FLOOR_DEEP, _DEEP_SOC, 30))
_c_shal, _r_shal = asyncio.run(_at_floor(_FLOOR_SHALLOW, _SHAL_SOC, 20))
_bd, _bs = _c_deep.inverters[1], _c_shal.inverters[1]
check(f"both cut-outs are configs an operator can enter - inside the shipped "
f"schema {read_option('bms_soc_floor', 'schema')} - and NEITHER is the "
f"{read_option('bms_soc_floor')} % this file and config.yaml's own "
f"example both state, which is the number a literal would have agreed "
f"with everywhere",
_floor_lo <= _FLOOR_SHALLOW < int(read_option("bms_soc_floor"))
< _FLOOR_DEEP <= _floor_hi)
check("...and each arm is a config validate.py rule 8 accepts: its "
"peak_soc_floor is above EVERY unit's own cut-out",
all(float(c.o.get("peak_soc_floor")) > float(i.bms_soc_floor)
for c in (_c_deep, _c_shal) for i in c.inverters))
check("...and the two arms are INSEPARABLE IN THE QUANTITIES THE CARD'S "
"PROPOSED DISCRIMINATOR READS - the same loop reason and over 900 W of "
"grid error on every one of the last eight cycles - which is part 4's "
"claim made again at two cut-outs that are not the shipped one. ⚠️ THEIR "
"SHARES DIFFER, because OPT-09 closes the DEEP arm's envelope (15 % "
"against a 25 % cut-out) and leaves the SHALLOW arm's open (8 % against "
"5 %); that is the site declining to ask, not a detector telling them "
"apart, and it is why the pin below still needs the pack's OWN number",
[r["reason"] for r in _r_deep[-8:]] == [r["reason"] for r in _r_shal[-8:]]
and all(abs(r["grid"]) > 900.0
for rows in (_r_deep, _r_shal) for r in rows[-8:]))
check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
f"cold, and is NOT named - even though it is far above the "
f"{read_option('bms_soc_floor')} % a literal would have graded it "
f"against, which would have called an empty pack a fault",
_r_deep[-1]["named"] == () and float(_bd.bms_soc_floor) == _FLOOR_DEEP
and _bd.soc == _DEEP_SOC)
check("...and it reaches that decision the way OPT-09 makes it reach it - the "
"pack's own 25 % cut-out closed its own envelope, so it is commanded 0 W "
"and has no discharge share to be short of - while `account()`, asked "
"directly on the same two numbers, STILL turns it away. Both halves, for "
"the reason part 4 gives: the second is what survives OPT-09 being "
"reverted, and it is what covers the absences OPT-09 will not close on",
float(_bd.standing_w()) == 0.0
and short_by(_bd.standing_w(), _bd.batt) is None
and account(live=True, parked=False, soc=_bd.soc,
bms_floor=_bd.bms_soc_floor) is not None)
check(f"a pack at {_SHAL_SOC:.0f} % against its OWN {_FLOOR_SHALLOW} % cut-out "
f"is WEDGED, and IS named - even though it is below the "
f"{read_option('bms_soc_floor')} % a literal would have explained it "
f"away with, which would have lost the fault this card exists to find. "
f"THIS ROW AND THE ONE ABOVE ARE THE PIN: no constant answers both",
_r_shal[-1]["named"] == ("inverter-b",)
and float(_bs.bms_soc_floor) == _FLOOR_SHALLOW and _bs.soc == _SHAL_SOC)
print(f" deep cut-out {_FLOOR_DEEP} %, SoC {_DEEP_SOC:.0f} % -> named "
f"{_r_deep[-1]['named']}")
print(f" shallow cut-out {_FLOOR_SHALLOW} %, SoC {_SHAL_SOC:.0f} % -> "
f"named {_r_shal[-1]['named']}")
# ===========================================================================
print()
print("=" * 72)
print("5. IT REPORTS AND DOES NOT ACT - asserted by counterfactual, not by")
print(" reading the code")
print("=" * 72)
async def _never_trips():
hass = _DeliveryHass(b_soc=50.0)
c = _ctrl(hass, delivery_hold_s=900) # the schema ceiling: never, here
return c, await _drive(c, watch=_sample)
_cn, _sn = asyncio.run(_never_trips())
check("the guard TRIPPED in one run and not in the other, so the comparison "
"below is between two different states of this detector",
_sw[-1]["named"] == ("inverter-b",) and _sn[-1]["named"] == ()
and _cw.inverters[1].delivery_wd.tripped
and not _cn.inverters[1].delivery_wd.tripped)
check("...and EVERY WATT IS THE SAME. Grid, site target, both shares and both "
"measured powers are identical cycle for cycle whether this guard "
"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)
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)
_pay = _cw.status_payload()
check("...and control is still running, which is the whole difference from "
"every other latched guard in this add-on",
_cw.auto and _pay["level"] != "bad")
check("...but the banner does NOT go on saying 'Running - holding grid at "
"zero'. That sentence is a claim the site cannot make while a unit is "
"absent, and 'the page reads healthy while the fault runs' is the shape "
"this whole card exists to remove. It names the unit and stays a WARNING",
_pay["level"] == "warn" and "is not delivering" in _pay["banner"]
and "inverter-b" in _pay["banner"]
and "holding grid at zero" not in _pay["banner"])
print(f" banner: {_pay['level']!r} / {_pay['banner']!r}")
_row = [r for r in _cw.checks()
if r.get("id") == "delivery" and "not delivering" in r["text"]]
check("the status page carries it as a CAUTION, not a failure: the site is "
"still running and short, which a red cross would misdescribe",
len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True
and "inverter-b" in _row[0]["text"])
check("...and the row says how it clears: a stop and start of control",
bool(_row) and "stopping and starting control" in _row[0]["text"])
# ===========================================================================
print()
print("=" * 72)
print("6. what the NEIGHBOURING guards conclude on this same fixture - the")
print(" honest version of 'every existing guard is silent'")
print("=" * 72)
_b = _cw.inverters[1]
check("SAFETY-30 per unit: SILENT. It needs OPPOSITION on both sides of "
f"{_cw.unit_sign_cfg.min_w:.0f} W, and a unit at 0 W is not opposing - "
"it is absent, so the gate never opens",
not _b.sign_wd.tripped and _b.sign_wd.disagree_since is None)
check("SAFETY-30 site: SILENT. The sum of the standing commands still agrees "
"with the meter in DIRECTION; only the magnitude is short",
not _cw.site_sign_wd.tripped and _cw.site_sign_wd.disagree_since is None)
check("SAFETY-31: SILENT, and identically so. sum(|Bi|) - |sum(Bi)| is ZERO "
"when one unit does nothing - circulation needs two units pulling "
"AGAINST each other",
_cw.site_circulating_w == 0.0 and not _cw.circ_wd.tripped)
check("the saturation freeze: FIRES - and that is the point. It is the one "
"signal that does, and clamp.py:93-96 documents it as the alarm an "
"operator learns to ignore, which is why this card needed a different "
"signal rather than a louder one",
_cw.sat_count >= _cw.tuning.saturation_cycles)
print(f" saturation counter {_cw.sat_count} (freezes at "
f"{_cw.tuning.saturation_cycles}); site commanded "
f"{_cw.target:.0f} W against {_cw.site_batt_w:.0f} W measured")
print(f" ...and on the benign arm: counter {_cf.sat_count}, site "
f"commanded {_cf.target:.0f} W against {_cf.site_batt_w:.0f} W measured")
check("...and it fires ON THE BENIGN ARM TOO, from the same site numbers. The "
"site is commanded the same watts against the same measured watts and is "
"frozen on both, many times over the configured cycle count. ⚠️ THE "
"COUNTERS DIFFER BY ONE CYCLE SINCE OPT-09, and that rescues nothing: the "
"cold arm reaches its steady state one cycle sooner because its healthy "
"sibling gets the whole share immediately instead of half of it. A "
"duration threshold that both arms exceed by an order of magnitude "
"cannot be set between them, which is the claim - and the two settled "
"states are identical to the watt",
_cf.site_batt_w == _cw.site_batt_w
and _cf.target == _cw.target
and min(_cf.sat_count, _cw.sat_count) >= _cw.tuning.saturation_cycles
and abs(_cf.sat_count - _cw.sat_count) <= 1)
print()
print(" ...and a unit the ALLOCATOR HAS ALREADY DROPPED is not reported twice")
# ⚠️ A WIRING CHECK THE PURE ONE IN PART 1 CANNOT MAKE, AND THE MUTATION BATTERY
# IS WHAT FOUND IT MISSING. `account(live=False, ...)` is graded up there; what
# is graded HERE is that `cycle()` actually ASKS `inv.is_live(now)`. Replacing
# that call with a literal `True` left every check green, because no fixture had
# a unit whose writes were being rejected. A dropped unit still has its last
# ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
# alone on a rejected write) and still measures nothing, so it looks exactly
# like a wedged one - and reporting it would be a second, louder alarm about a
# state the allocator has already handled and logged, which is the
# SAFETY-15/SAFETY-19 failure this module takes its 30 s floor from.
class _LinkDeadHass(_DeliveryHass):
"""B is healthy, then its LINK dies: writes rejected and output gone.
⚠️ `rejected_since` IS BACK-DATED RATHER THAN WAITED OUT. `is_live()`'s
window is `LINK_STALE_S` = 30 s of wall clock and this fixture runs in about
two; waiting it out would make the check's runtime the thing it grades. What
is graded is the CALL, not the timer - `test_inverter.py` part 3 owns the
timer.
"""
def __init__(self, dead_from=5, **kw):
super().__init__(**kw)
self.dead_from = int(dead_from)
self.cycle_n = 0
def measured(self):
a, b = self.ctrl.inverters
w = lambda i: float((i.last_write if i.last_write is not None
else i.target) or 0.0)
return [w(a), 0.0 if self.cycle_n >= self.dead_from else w(b)]
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
if entity == "number.sp_b" and self.cycle_n >= self.dead_from:
return False
return True
async def _link_dead():
hass = _LinkDeadHass(dead_from=5)
c = _ctrl(hass)
b = c.inverters[1]
seen = []
for n in range(18):
hass.cycle_n = n
if n == 5:
# the moment the link dies, back-dated past the firmware's window
b.rejected_since = (datetime.now(timezone.utc)
- timedelta(seconds=LINK_STALE_S + 10))
await c.read_inputs()
await c.cycle()
seen.append(dict(live=b.is_live(datetime.now(timezone.utc))[0],
stood=float(b.standing_w()), meas=b.batt,
named=b.delivery_wd.tripped))
await asyncio.sleep(0.15)
return c, seen
_cld, _sld = asyncio.run(_link_dead())
_bld = _cld.inverters[1]
check("the fixture REACHES the case: B is not live, is still standing at a "
"discharge command, and is measuring nothing - i.e. it looks exactly like "
"a wedged unit to every number this check reads",
not _sld[-1]["live"] and _sld[-1]["stood"] > _cld.tuning.saturation_w
and _sld[-1]["meas"] == 0.0)
check("...and it is NOT reported, because `account()` was asked and answered. "
"The allocator has already dropped it and said so in the action log; a "
"second alarm about that is the SAFETY-15/19 failure",
not _bld.delivery_wd.tripped and _bld.delivery_wd.since is None)
check("...and the answer is the LINK one specifically, not the SoC one - so a "
"reader can tell which branch withheld the alarm",
"link" in (account(live=False, parked=False, soc=_bld.soc,
bms_floor=_bld.bms_soc_floor) or ""))
print(f" B: live={_sld[-1]['live']} standing={_sld[-1]['stood']:.0f} W "
f"measured={_sld[-1]['meas']:.0f} W -> not reported")
print()
print(" ...and the one case where BOTH have something to say: a unit that is")
print(" REVERSED is short too, and SAFETY-30 gets there first")
# ⚠️ THE OVERLAP IS REAL AND IT RESOLVES ITSELF, WHICH IS WORTH ASSERTING
# RATHER THAN ASSUMING. A unit measured at -2000 W against a +2000 W share is
# OPPOSING (SAFETY-30's fault) and is also short by 4000 W with nothing to
# explain it (this one). SAFETY-30's hold is shorter, it parks the unit, the
# share goes to 0 W - and this check's gate closes on `share > 0` while
# `account()` answers "parked". Two alarms for one fault is how an operator
# learns to skim, and this is the arrangement that prevents it.
class _ReversedHass(_DeliveryHass):
"""Unit B does the OPPOSITE of what it is told - SAFETY-30's fault."""
def measured(self):
a, b = self.ctrl.inverters
w = lambda i: float((i.last_write if i.last_write is not None
else i.target) or 0.0)
return [w(a), -w(b)]
async def _reversed_run():
hass = _ReversedHass(b_soc=50.0)
# SAFETY-30's per-unit hold SHORTER than this check's, which is the shipped
# ordering too: 85 s against 185 s.
c = _ctrl(hass, unit_sign_disagree_s=1, delivery_hold_s=2)
seen = await _drive(c, n=24, dt=0.15, watch=lambda c: dict(
sign=c.inverters[1].sign_wd.tripped,
deliv=c.inverters[1].delivery_wd.tripped,
share=float(c.inverters[1].target),
acct=account(live=True, parked=c.inverters[1].sign_wd.tripped,
soc=c.inverters[1].soc,
bms_floor=c.inverters[1].bms_soc_floor)))
return c, seen
_crev, _srev = asyncio.run(_reversed_run())
check("a REVERSED unit is short by more than saturation_w too, so both guards "
"have evidence - the fixture reaches the overlap rather than dodging it",
any(r["deliv"] or r["sign"] for r in _srev)
and any(abs(r["share"]) > _crev.tuning.saturation_w for r in _srev))
check("SAFETY-30 gets there first and PARKS it - its hold is shorter, which is "
"also the shipped ordering (85 s against 185 s)",
_crev.inverters[1].sign_wd.tripped)
check("...and once parked, this check goes quiet on its own: the share is 0 W "
"so it is no longer ASKED, and `account()` answers 'parked' anyway. ONE "
"alarm for one fault",
not _crev.inverters[1].delivery_wd.tripped
and _crev.inverters[1].delivery_wd.since is None
and float(_crev.inverters[1].target) == 0.0
and _srev[-1]["acct"] is not None)
print(f" shipped ordering: unit sign hold "
f"{_crev.unit_sign_cfg.hold_s:.0f} s in this fixture, delivery hold "
f"{_crev.delivery_cfg.hold_s:.0f} s; account now {_srev[-1]['acct']!r}")
# ===========================================================================
print()
print("=" * 72)
print("7. AC 5: AT ONE INVERTER NOTHING CHANGES - asserted, not assumed")
print("=" * 72)
class _OneHass(_DeliveryHass):
"""One inverter that produces nothing against a non-zero share."""
def measured(self):
return [0.0]
async def number(self, entity, invert=False):
return {"sensor.grid": self.load_w, "sensor.soc": 50.0,
"sensor.batt": 0.0}.get(entity)
async def _one():
hass = _OneHass()
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6,
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock
# against a 1 s window, so a hold is not what stops this firing -
# the reduction is. With a shipped-length hold "nothing was
# reported" would be true of a fixture that simply ran out of time.
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
"sign_disagree_s": 3600, "auto_start": True}
store = _MemStore()
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -2000.0, 2000.0
c.auto = True
hass.ctrl = c
await _drive(c, n=20, dt=0.1)
return c, hass, store
_c1, _h1, _st1 = asyncio.run(_one())
_only = _c1.inverters[0]
check("a roster of ONE, whose only unit is standing at a discharge share and "
"delivering nothing for the whole run - the exact fault, at N=1",
len(_c1.inverters) == 1 and float(_only.standing_w()) > 0
and _only.batt == 0.0
and short_by(_only.standing_w(), _only.batt) > _c1.tuning.saturation_w)
check("...and NOTHING is reported. With no sibling, 'this box cannot' and "
"'this plant cannot' are the same observation, and the saturation freeze "
"is still the whole answer there", not _only.delivery_wd.tripped)
check("...the window was never even opened", _only.delivery_wd.since is None)
check("...and no store key was written, so a one-inverter install's state file "
"is byte-identical to what it was before this card",
unit_key(_only.name, "delivery_tripped") not in _st1.data
and unit_key(_only.name, "delivery_reason") not in _st1.data)
_n1row = [r for r in _c1.checks() if r.get("id") == "delivery"]
check("...and the status page says n/a rather than 'armed', which would be a "
"claim to be watching something that cannot happen",
len(_n1row) == 1 and _n1row[0]["ok"]
and "not needed with one inverter" in _n1row[0]["text"])
check("...no persistent notification of this kind was raised either",
not [c for c in _h1.calls
if "not_delivering" in str(c[2].get("notification_id", ""))])
# ===========================================================================
print()
print("=" * 72)
print("8. SAFETY-34: the report survives a Home Assistant restart, which is")
print(" how the 2026-09-02 outage went unseen for 6 h 25 m")
print("=" * 72)
async def _restart():
shared = _MemStore()
h1 = _DeliveryHass(b_soc=50.0)
c1 = _ctrl(h1, store=shared)
await _drive(c1)
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
# is gone with it), the SAME durable file: an ADD-ON restart. A Core-only
# restart keeps the process; graded in part 8b (SAFETY-34).
h2 = _DeliveryHass(b_soc=50.0)
c2 = _ctrl(h2, store=shared)
await c2.check_not_delivering()
return c1, h1, c2, h2, shared
_r1, _rh1, _r2, _rh2, _rs = asyncio.run(_restart())
_key = unit_key("inverter-b", "delivery_tripped")
check("the latch reached the durable file, namespaced under the unit it is "
"about", _rs.data.get(_key) is True)
check("...with its reason, and the reason was written FIRST - a crash between "
"the two must not persist a latch nobody can explain",
bool(_rs.data.get(unit_key("inverter-b", "delivery_reason"))))
check("...and there is NO flat mirror of it, where SAFETY-02 has one: the "
"version a rollback would land on drives exactly one actuator, a roster "
"at which this check is inert by construction",
"delivery_tripped" not in _rs.data and "delivery_reason" not in _rs.data)
check("a fresh process comes up with the report STILL STANDING",
_r2.inverters[1].delivery_wd.tripped)
check("...and RE-RAISES the notification, because a persistent_notification is "
"in memory and did not survive - that is the whole of SAFETY-34",
any(c[0] == "persistent_notification"
and "not_delivering" in str(c[2].get("notification_id", ""))
for c in _rh2.calls))
_note = [c for c in _rh2.calls
if "not_delivering" in str(c[2].get("notification_id", ""))][0]
check("the notification NAMES THE UNIT - in its id, its title and its body - "
"which is card requirement 2 and the whole reason SAFETY-30 split "
"per-unit from site",
"inverter-b" in _note[2]["notification_id"].replace("_", "-")
and "inverter-b" in _note[2]["title"]
and "inverter-b" in _note[2]["message"])
check("...and it says, in the operator's own sentence, that nothing has been "
"commanded differently. An alarm that let somebody believe the add-on "
"had handled it would be worse than no alarm",
"nothing was changed and control keeps running" in _note[2]["message"])
print(f" id={_note[2]['notification_id']!r} title={_note[2]['title']!r}")
_before_calls = len(_rh2.calls)
await_again = asyncio.run(_r2.check_not_delivering())
check("...and it is raised ONCE per process, not once per cycle: the alarm "
"storm this add-on has argued against twice already",
len(_rh2.calls) == _before_calls)
_r2.auto = False
asyncio.run(_r2.handle_action("auto_toggle")) # off
asyncio.run(_r2.handle_action("auto_toggle")) # and on again: the press
check("the operator's START press is the only way out, and it clears the "
"latch, the reason and the 'a human has seen this' flag together",
not _r2.inverters[1].delivery_wd.tripped
and _rs.data.get(_key) is False
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
and _r2._delivery_announced == set())
print()
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
import app.main as _main_mod # noqa: E402
async def _core_restart():
shared = _MemStore()
c = _ctrl(_DeliveryHass(b_soc=50.0), store=shared)
await _drive(c)
return c
_c = asyncio.run(_core_restart())
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_c.hass = _DeliveryHass(b_soc=50.0)
asyncio.run(_c.check_not_delivering())
_early = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_c.check_not_delivering())
_late = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: the latch tripped in this process",
_c.inverters[1].delivery_wd.tripped)
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
len(_early) == 0)
check("SAFETY-34: after REANNOUNCE_S the SAME process re-asserts the card, "
"because a Core-only restart wiped it and did not restart the add-on",
len(_late) == 1 and "inverter_b" in _late[0][2]["notification_id"])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")