Files
goodwe-addon/goodwe_controller/test_watchdog.py
T
Claude review 569f16aa91
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 1s
UX-08 2d review: action log reachable in status JSON, engaged peak row pinned, imported keys expanded
- status_payload() also returns the action log as `events`. The page
  does not render it. Some entries (a dropped inverter's reason, the peak
  guard's projection, unplaced watts) are written nowhere else, and DOCS
  still points installers at the action log.
- test_peakguard pins the engaged row's ordered pair (the battery's share,
  then the limit).
- test_messages expands setting keys named by constants imported from
  another app module, so the shown-text 30-word scan counts them.
- p1.py: 'A meter message could not be read'. DOCS glossary: control
  board in plain words, with the hardware in an installer note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:29:14 +02:00

3060 lines
159 KiB
Python

"""Runnable check for the sign-disagreement watchdog. `python3 test_watchdog.py`
Every case here is either a fault this must catch or a piece of NORMAL behaviour
it must not mistake for one. The second list is the longer one on purpose: an
alarm that fires on ordinary operation is worse than no alarm, because the next
person switches it off.
The headline validation is not here - it is `sim/replay_sign.py` in the infra
repo, which drives this module over the two captured bus days. This file is the
table-driven half: boundaries, timers, the latch, and the arming gate.
⚠️ 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, and a size-preserving
edit reverted inside one second is invisible to CPython's staleness test. 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 sys
from datetime import datetime, timedelta, timezone
from named_absence import nth, present
from app import messages # noqa: E402
from app.watchdog import (
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
fails = []
T0 = datetime(2026, 8, 20, 15, 0, 0, tzinfo=timezone.utc)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def drive(samples, cfg=None, following=True):
"""Feed (offset_s, commanded_w, grid_w) tuples. Returns (wd, trip_offset)."""
wd = SignWatchdog(cfg)
trip_at = None
for item in samples:
offset, cmd, grid = item[0], item[1], item[2]
f = item[3] if len(item) > 3 else following
if wd.evaluate(cmd, grid, f, T0 + timedelta(seconds=offset)) and trip_at is None:
trip_at = offset
return wd, trip_at
def opposed(duration_s, step=5, cmd=-1000, grid=1500, following=True, start=0):
return [(start + t, cmd, grid, following)
for t in range(0, int(duration_s) + 1, step)]
print("the fault it exists to catch")
wd, at = drive(opposed(300))
check("charging into an importing house trips", wd.tripped)
check("and trips at 180 s, not before", at == 180)
check("the reason names both signals", "-1000" in wd.reason and "+1500" in wd.reason)
print(f" e.g. {wd.reason!r}")
wd, at = drive(opposed(300, cmd=2000, grid=-2000))
check("discharging into an exporting house trips too", wd.tripped and at == 180)
print()
print("the boundaries")
wd, _ = drive(opposed(600, cmd=-300, grid=1500))
check("300 W exactly does NOT qualify (strictly greater)", not wd.tripped)
wd, _ = drive(opposed(600, cmd=-301, grid=1500))
check("301 W does", wd.tripped)
wd, _ = drive(opposed(600, cmd=-1000, grid=300))
check("a 300 W meter does not qualify either", not wd.tripped)
wd, _ = drive(opposed(175))
check("175 s of disagreement does not trip", not wd.tripped)
wd, _ = drive(opposed(180))
check("180 s does (>= hold_s)", wd.tripped)
print()
print("continuity - the rule that produced zero false positives on real data")
broken = opposed(175) + [(180, -1000, -1500)] + opposed(175, start=185)
wd, _ = drive(broken)
check("179 s, one agreeing sample, then 175 s more: no trip", not wd.tripped)
check("and the window really was reset", wd.disagree_since is not None)
long_enough = broken + opposed(200, start=365)
wd, _ = drive(long_enough)
check("but a full 180 s after the reset does trip", wd.tripped)
print()
print("things that are NOT faults")
# The load-step transient. Measured on the healthy capture: longest 30 s.
wd, _ = drive(opposed(30) + [(35 + t, 800, 900) for t in range(0, 300, 5)])
check("a 30 s load-step crossover does not trip", not wd.tripped)
# SoC 10 %, Standby, declining a 1350 W discharge. The battery does nothing, so
# the meter stays where it was and the command agrees with it.
wd, _ = drive([(t, 1350, 1350) for t in range(0, 900, 5)])
check("a battery refusing a discharge command cannot trip it", not wd.tripped)
# A frozen meter drives command and reading into agreement, not disagreement.
wd, _ = drive([(t, 800, 800) for t in range(0, 900, 5)])
check("a frozen meter cannot trip it", not wd.tripped)
# Maintenance grid charge: 2500 W of charge against an importing house, for
# hours. Opposed, both huge - and entirely correct.
wd, _ = drive(opposed(7200, cmd=-2500, grid=3000, following=False))
check("two hours of maintenance grid charge does not trip", not wd.tripped)
check("and the timer never even started", wd.disagree_since is None)
wd, _ = drive([(t, -1000, None) for t in range(0, 900, 5)])
check("a missing meter reading does not trip", not wd.tripped)
wd, _ = drive(opposed(175) + [(180, -1000, None)] + opposed(600, start=185))
check("a missing reading mid-window RESETS it rather than accumulating",
wd.tripped and wd.disagree_since is not None)
wd, at = drive(opposed(175) + [(180, -1000, None)] + opposed(600, start=185))
check("...so the trip lands 180 s after the gap, not after 180 s total",
at == 365)
wd, _ = drive([(t, 0.0, 3000) for t in range(0, 900, 5)])
check("a 0 W command is not a wrong-direction command", not wd.tripped)
print()
print("the latch")
wd, _ = drive(opposed(200))
check("tripped after the fault", wd.tripped)
# The trip commands 0 W, which removes the command side of the comparison.
for t in range(200, 900, 5):
wd.evaluate(0.0, 3000, True, T0 + timedelta(seconds=t))
check("stays tripped once the command is zeroed", wd.tripped)
for t in range(900, 1500, 5):
wd.evaluate(1000, 1000, True, T0 + timedelta(seconds=t))
check("stays tripped even once everything looks healthy again", wd.tripped)
wd.reset()
check("reset() is the only way out", not wd.tripped and wd.disagree_since is None)
check("and the reason is cleared with it", wd.reason == "")
print()
print("the thresholds are configuration, not constants")
cfg = SignWatchdogConfig(min_w=800.0, hold_s=60.0)
wd, at = drive(opposed(300, cmd=-1000, grid=1500), cfg=cfg)
check("a 60 s hold trips at 60 s", wd.tripped and at == 60)
wd, _ = drive(opposed(600, cmd=-700, grid=1500), cfg=cfg)
check("an 800 W gate ignores a 700 W command", not wd.tripped)
print()
print("the configuration that would disarm it is refused")
from app.validate import validate_startup_config # noqa: E402
base = {"setpoint_entity": "number.sp", "meter_source": "off",
"meter_phases": 1, "meter_max_age_s": 30}
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 300},
-5000, 5000)
check("300 W under a 2000 W ceiling is accepted", p == [])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
-5000, 5000)
check("a threshold AT max_w is refused", len(p) == 1 and "can never trip" in p[0])
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
# max_w is also below the 2000 W shipped maintenance charge - so without this
# the dict is refused twice, for two correct but different reasons, and the
# `len(p) == 1` below would be measuring the interaction rather than this rule.
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
"maintenance_charge_w": 500}, -5000, 5000)
check("a threshold above max_w is refused", len(p) == 1 and "can never trip" in p[0])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
-5000, 5000)
check("one watt under the ceiling is reachable, so accepted", p == [])
print()
print("the arming gate, derived from the REAL arbiter")
# ⚠️ THIS IS THE DECISION THE WHOLE TICKET TURNS ON, and until now it was
# asserted in three comment blocks and verified by nothing: every other case in
# this file passes `following` in as a literal. What has to hold is that the
# expression in main.py - loop won AND the emitted value points the way the loop
# asked AND is no larger - is never True for a resolution the loop did not
# actually get. So run real claims through the real resolve() and derive it.
from app.arbiter import ( # noqa: E402
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
)
def following_from(claims, asked):
"""THE SHIPPED gate, not a copy of it.
⚠️ This used to transcribe main.py's expression with a comment saying
"keep the two in step", which is exactly the arrangement that does not keep
two things in step. Measured: editing the shipped gate alone to
`winner in ("loop", "peak")` left this suite - including the P_PEAK row below,
whose entire job is to catch that edit - completely green, while the watchdog
went blind for the duration of every peak event. Import it or the P_PEAK
assertion is decoration.
"""
r = resolve(claims)
return r, loop_is_following(r, asked)
MAXW = Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w")
DEV = Claim.limit("device", P_SAFETY, -5000, 5000, "rating")
# name, claims, what the loop asked, expected `following`
TABLE = [
("the loop alone, unopposed",
[Claim.set("loop", P_LOOP, 900)], 900, True),
("the loop bound by max_w - reduced, not redirected",
[Claim.set("loop", P_LOOP, 4000), MAXW], 4000, True),
("charging, bound by max_w on the other side",
[Claim.set("loop", P_LOOP, -4000), MAXW], -4000, True),
("maintenance owns the setpoint outright",
[Claim.set("loop", P_LOOP, 900),
Claim.set("maintenance", P_MAINTENANCE, -2500, "charge"), MAXW], 900, False),
("maintenance charge-only clamps the loop to 0 W",
[Claim.set("loop", P_LOOP, 900),
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only"),
MAXW], 900, False),
# ⚠️ THE CASE `winner == \"loop\"` ALONE WOULD HAVE GOT WRONG. 1500 W, not
# 2500 W: at max_w 2000 a -2500 hi against a -2000 lo is an arbiter
# contradiction, not a charge. That pair was the SHIPPED default until
# MAINT-04 refused it and CFG-03 gave the default one source of truth; it is
# kept below as a case rather than as a configuration.
("cheap-window charge REDIRECTS the loop while it still 'wins'",
[Claim.set("loop", P_LOOP, 500),
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-1500, reason="cheap-window"),
MAXW], 500, False),
("...and the contradiction when charge_w exceeds max_w is not following either",
[Claim.set("loop", P_LOOP, 500),
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-2500, reason="cheap-window"),
MAXW], 500, False),
("a lo limit RAISES the magnitude without flipping the sign",
[Claim.set("loop", P_LOOP, 200),
Claim.limit("floor", P_SAFETY, lo=800), MAXW], 200, False),
("a safety stop wins outright",
[Claim.set("loop", P_LOOP, 900),
Claim.set("safety", P_SAFETY, 0.0, "stopped"), MAXW], 900, False),
("no claims at all - the arbiter's own failsafe",
[MAXW, DEV], None, False),
("the loop asked for zero",
[Claim.set("loop", P_LOOP, 0.0), MAXW], 0.0, False),
# The forward hazard, asserted as it stands TODAY rather than as it should
# be. When peak shaving lands, these expectations are what have to change.
("peak shaving at P_PEAK disarms the gate (loop_is_following's forward hazard)",
[Claim.set("loop", P_LOOP, 900),
Claim.set("peak", P_PEAK, 1800, "shaving"), MAXW], 900, False),
# ⚠️ AND THE ROW ABOVE CANNOT DETECT A WIDENED GATE ON ITS OWN, which was
# found by trying it: with the shipped gate edited to
# `winner in ("loop", "peak")` that case STILL comes out False, because peak
# shaving asked for MORE than the loop and the magnitude term rejects it
# anyway. The winner term is masked. This row isolates it - a peak claim
# smaller than the loop's ask and pointing the same way, so `winner` is the
# only term that can decide - and it is what actually turns red if the gate
# is widened without anyone revisiting the hazard.
("...and with peak BELOW the loop's ask, only the winner term separates them",
[Claim.set("loop", P_LOOP, 1800),
Claim.set("peak", P_PEAK, 900, "shaving"), MAXW], 1800, False),
]
for name, claims, asked, expect in TABLE:
r, got = following_from(claims, asked)
check(f"{name} -> following={expect}", got is expect)
if got is not expect:
print(f" resolution was {r.explain()!r}")
# And the gate is never True when the loop did not win, whatever else is going on.
check("no configuration above arms the gate without the loop winning",
all(not following_from(c, a)[1]
for _, c, a, _ in TABLE if following_from(c, a)[0].winner != "loop"))
print()
print("end to end: the trip is COMMAND ZERO, not alarm-only")
# ⚠️ THE FAULT INJECTED HERE IS A NEGATIVE GAIN, and that is not a contrivance -
# it is the minimal faithful model of what this watchdog is for. The check
# compares the loop's OUTPUT DIRECTION against the INPUT STATE it was regulating,
# so what it detects is a loop whose two ends have become decoupled in sign: a
# gain, a claim or a strategy with its polarity inverted, or an integrator so far
# wound up that unwinding takes minutes. (Illusmart's was the last one: it
# reached -14 768 W against a +-5000 W rail while writing once every 20-60 s, and
# that arithmetic alone is 5-15 minutes of wrong-direction command.)
#
# `gain` is float(0.05,1.0) in the schema, so this is not a reachable
# configuration - it is a stand-in for a code regression, which is the case a
# unit test can actually hold the line on.
import asyncio # noqa: E402
from app.main import Controller # noqa: E402
# ⚠️ DEV-02. THE KEY SPELLING IS IMPORTED, NEVER TRANSCRIBED. `sign_tripped` and
# `sign_reason` are now NAMESPACED PER INVERTER - flat, a trip caused by B
# latched A across every restart and one operator press cleared both. A check
# that spelled `"unit/inverter-a/sign_tripped"` by hand here would go on passing
# against a main.py that had started writing somewhere else, which is this
# repository's own named failure mode: the copy that drifts is always the
# grading one. `unit_key()` is the one definition and `Inverter.key()` calls it.
from app.inverter import UNIT_A # noqa: E402
from app.store import unit_key # noqa: E402
TRIPPED_KEY = unit_key(UNIT_A, "sign_tripped")
REASON_KEY = unit_key(UNIT_A, "sign_reason")
class _Hass:
def __init__(self):
self.writes = []
self.calls = []
# ⚠️ ADDED FOR SAFETY-03, AND THE REASON IS WORTH READING. The e2e below
# runs a DELIBERATELY INVERTED loop (gain -0.6) into a steady 2000 W import,
# which is a loop that winds straight into the rail - so the output-clamp
# alarm fires here, and the stub not having `call` is how that was
# discovered. This scenario is now a second, independent witness that the
# clamp alarm sees a real winding loop; test_clamp.py asserts it explicitly.
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
# A house importing 2000 W, steadily, whatever the battery does.
return {"sensor.grid": 2000.0, "sensor.soc": 50.0,
"sensor.batt": 0.0}.get(entity)
async def set_number(self, entity, value):
self.writes.append(float(value))
return True
class _S:
data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _P:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
async def _e2e():
hass = _Hass()
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,
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
c = Controller(opts, hass, _S(), _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
seen_wrong_way = False
for _ in range(12):
await c.read_inputs()
await c.cycle()
if c.target < -300 and not c.sign_wd.tripped:
seen_wrong_way = True
await asyncio.sleep(0.25)
return c, hass, seen_wrong_way
ctrl, hass, wrong_way = asyncio.run(_e2e())
check("the broken loop really did command charge into an import first", wrong_way)
check("the watchdog tripped", ctrl.sign_wd.tripped)
check("the commanded value is 0 W", ctrl.target == 0.0)
check("0 W actually reached the device", 0.0 in hass.writes)
check("and it got there through the failsafe claim, not by accident",
"failsafe" in ctrl.reason)
print(f" status line: {ctrl.reason!r}")
check("the status page says so in words: a red row, at 0 W, cleared by start",
any(c.get("id") == "direction" and not c["ok"] and not c["warn"]
and "set to 0 W" in c["text"] and "start control" in c["text"]
for c in ctrl.checks()))
check("the banner leads with it",
ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("...the line under the buttons says it in plain words, and the action "
"log with the raw reason is still reachable in the status JSON",
ctrl.status_payload()["hint"].endswith(
messages.hint("direction", unit=ctrl.inverters[0].name))
and ctrl.status_payload()["events"] == ctrl.events
and any(e.split(" ", 1)[1].startswith("FAILSAFE ")
for e in ctrl.status_payload()["events"]))
check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
# ⚠️ THIS USED TO BE `check("MQTT reports the failsafe state", ctrl.sign_wd.tripped)`
# which restates the assertion three lines above and never touches the payload.
# The `status: "failsafe"` value was shipped untested. Capture what publish()
# actually hands the broker.
published = {}
ctrl.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: published.update(v),
"close": lambda _s: None})()
ctrl.publish()
check("MQTT's controller-status sensor reports 'failsafe'",
published.get("status") == "failsafe")
ctrl.sign_wd.reset()
ctrl.publish()
check("...and goes back to 'running' once cleared",
published.get("status") == "running")
print()
print("the latch survives a restart - it is the only thing between a tripped")
print("controller and the first thing an operator will try")
# ⚠️ THE BLOCKER THIS FILE MISSED FIRST TIME ROUND. `auto` is persisted and
# `tripped` was not, so reconstructing a Controller against the same Store -
# which is exactly what a container restart does - came back armed, into the
# fault, with no operator involved: tripped=False, banner NOT READY, and it
# resumed commanding -1000 then -2000 W before re-tripping 180 s later.
class _MemStore:
"""A Store that keeps its dict across "restarts", like the real file does.
⚠️ SEEDED FROM `store.DEFAULTS`, NOT FROM A LITERAL. `Store.load()` merges
that dict over whatever is on disk, so a fake that hand-listed three keys
answered `None` where the real store answers a default - and a check reading
"this latch was never written" then could not tell an absent key from a
False one. It is also a place to drift: SAFETY-31 and SAFETY-30 both added
flat pairs, and neither would have appeared here.
"""
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
async def _restart():
shared = _MemStore()
hass1 = _Hass()
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,
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
a = Controller(opts, hass1, shared, _P(), stop=asyncio.Event())
a.auto = True
a.dev_min, a.dev_max = -5000.0, 5000.0
for _ in range(12):
await a.read_inputs()
await a.cycle()
await asyncio.sleep(0.25)
# The restart. Same options, same store, brand new Controller and Hass.
hass2 = _Hass()
b = Controller(opts, hass2, shared, _P(), stop=asyncio.Event())
b.auto = True
b.dev_min, b.dev_max = -5000.0, 5000.0
for _ in range(6):
await b.read_inputs()
await b.cycle()
await asyncio.sleep(0.05)
return a, b, shared, hass2
pre, post, store, hass2 = asyncio.run(_restart())
check("it tripped before the restart", pre.sign_wd.tripped)
check("the trip reached the store, UNDER THE TRIPPED INVERTER'S OWN KEY",
store.data[TRIPPED_KEY] is True)
check("...with the reason beside it, so the operator is told why",
"SIGN_DISAGREEMENT" in store.data[REASON_KEY])
check("⚠️ ...AND THE FLAT PRE-DEV-02 KEY IS MIRRORED, AS THE ROLLBACK VIEW. "
"This assertion used to read the other way - that the flat key was NOT "
"written - on the argument that writing it would latch every other "
"inverter on the site. That argument was right about the READ and wrong "
"about the WRITE: nothing at runtime reads the flat key, and the one "
"thing that does (`_grade_store_migration()`, on every start) ignores it "
"whenever ANY namespaced latch is set, which a sibling's trip always "
"leaves. What the absence actually cost was the ROLLBACK: store.py's "
"rule 2 keeps this key so a downgrade finds the latch, and a trip taken "
"after the migration wrote nothing here - so an operator who downgraded "
"came up DRIVING. `test_inverter.py` part 3 is where 'B's trip does not "
"latch A across a restart' is graded, on a real roster of two",
store.data.get("sign_tripped") is True
and "SIGN_DISAGREEMENT" in str(store.data.get("sign_reason", "")))
check("the new Controller comes back TRIPPED", post.sign_wd.tripped)
check("...and says why, restored rather than re-detected",
"SIGN_DISAGREEMENT" in post.sign_wd.reason)
check("...commands 0 W from its very first cycle", post.target == 0.0)
check("...and never commanded anything else after the restart",
hass2.writes != [] and set(hass2.writes) == {0.0})
check("the banner still leads with the failsafe",
post.status_payload()["banner"] == messages.banner_direction(False))
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
# tripped, so the first press stops control; only the transition back to True
# clears. That is the documented behaviour and it is worth an assertion, because
# a one-press clear would be reachable by an operator who never read the banner.
asyncio.run(post.handle_action("auto_toggle")) # -> stopped
check("pressing stop does not clear the failsafe",
post.sign_wd.tripped and store.data[TRIPPED_KEY] is True)
asyncio.run(post.handle_action("auto_toggle")) # -> started
check("pressing start DOES clear it, and clears the store with it - BOTH "
"spellings, which is what stops the every-start latch guard re-arming a "
"failsafe the operator has already dealt with",
not post.sign_wd.tripped and store.data[TRIPPED_KEY] is False
and store.data[REASON_KEY] == ""
and store.data.get("sign_tripped") is False)
# One more restart from the cleared store, to prove the clear persisted too.
fresh = Controller({"setpoint_entity": "number.sp", "meter_source": "off"},
_Hass(), store, _P(), stop=asyncio.Event())
check("...so the next restart comes back armed and untripped, as it should",
not fresh.sign_wd.tripped)
# ⚠️ THE WRITE ORDERING IS AN INVARIANT, SO IT GETS AN ASSERTION. main.py writes
# `sign_reason` and THEN `sign_tripped`, each one its own `save()`, so that a
# process that dies between the two leaves a reason nothing reads - harmless -
# rather than a latch with no explanation for the operator who has to decide
# whether to clear it. Documented reasoning is not enforcement: swapping those two
# lines used to pass every check in this file.
#
# The death is modelled as the second write raising. In the field it is a power
# cut or a container kill (Store.save() swallows OSError rather than raising), so
# what is asserted here is the PROPERTY - what survives on disk - not the
# mechanism.
class _CrashOnFlag(_MemStore):
def set(self, k, v):
if k == TRIPPED_KEY and v:
raise RuntimeError("power cut, mid-trip")
super().set(k, v)
async def _crash_mid_write():
store = _CrashOnFlag()
hass = _Hass()
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,
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
died = False
for _ in range(12):
try:
await c.read_inputs()
await c.cycle()
except RuntimeError:
died = True
break
await asyncio.sleep(0.25)
return store, died
crashed_store, died = asyncio.run(_crash_mid_write())
check("the second write is the one that sets the flag", died)
check("a crash between the two writes leaves the REASON on disk",
"SIGN_DISAGREEMENT" in crashed_store.data.get(REASON_KEY, ""))
check("...and never a latch with nothing to explain it",
not crashed_store.data.get(TRIPPED_KEY))
# =============================================================================
print()
print("=" * 72)
print("SAFETY-31: circulating power, which nets to zero at the meter")
print("=" * 72)
# ⚠️ READ THIS BEFORE ADDING A CHECK HERE. Half of this card was already built
# and it is NOT the half below. ALLOC-01 shipped the SIGN-COHERENCE GATE
# (`allocator.REFUSE_SIGN`, graded by test_alloc.py part 6): an allocation whose
# shares carry two signs is REFUSED, both units named, no shares returned. So
# the allocator can no longer COMMAND circulating power, and nothing here
# re-tests that.
#
# What is graded here is the MEASUREMENT half: circulating power the allocator
# NEVER COMMANDED. The two are complements, not duplicates - the gate refuses an
# allocation before it is written and cannot see one millisecond past that; this
# reads the hardware and does not care what was written.
from app.inverter import LINK_STALE_S # noqa: E402
from app.circulation import ( # noqa: E402
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
circulating_w, opposing,
)
print()
print("1. the sensor: sum(|Bi|) - |sum(Bi)| over MEASURED per-unit power")
check("both units discharging: the triangle equality holds and it is ZERO. "
"This is the ordinary case and the sensor must be silent in it",
circulating_w([1000.0, 2000.0]) == 0.0)
check("...both charging, likewise", circulating_w([-1000.0, -2000.0]) == 0.0)
check("...and a unit resting at exactly zero beside a working one is not "
"opposition", circulating_w([0.0, 2500.0]) == 0.0)
# ⚠️ ASSERTED AGAINST A LITERAL, NOT AGAINST `2 * min(abs(a), abs(b))`.
# MUTATION.md's rule 2: an assertion written in terms of the expression under
# test cannot tell "the right formula" from "the same wrong formula twice".
check("A charging 3000 W while B discharges 3000 W reads 6000 W - the card's "
"own scenario, and the figure is 2*min(|A|,|B|), the power going round "
"the loop through BOTH conversion stages",
circulating_w([-3000.0, 3000.0]) == 6000.0)
check("...unequal opposition is bounded by the smaller unit: -3000 against "
"+1000 is 2000 W, not 4000",
circulating_w([-3000.0, 1000.0]) == 2000.0)
print(f" [-3000, +3000] -> {circulating_w([-3000.0, 3000.0]):.0f} W "
f"[-3000, +1000] -> {circulating_w([-3000.0, 1000.0]):.0f} W "
f"[+1000, +2000] -> {circulating_w([1000.0, 2000.0]):.0f} W")
check("three units, one out of step: it is defined over the whole roster, not "
"over a pair", circulating_w([-2000.0, 1000.0, 1000.0]) == 4000.0)
print()
print(" AC 5: at N=1 it is IDENTICALLY zero, for every reading")
# ⚠️ SWEPT, NOT SAMPLED. "Identically zero" is a claim about EVERY value, and
# one call with one number cannot distinguish it from "zero for this number".
_n1 = [circulating_w([v]) for v in
(0.0, 1.0, -1.0, 2500.0, -2500.0, 5000.0, -5000.0, 1e9, -1e9)]
check("nine readings from -1e9 to +1e9 W, every one of them 0.0",
set(_n1) == {0.0})
check("...and an EMPTY roster is zero too, not a crash - `Controller.publish()` "
"calls this before anything has been read",
circulating_w([]) == 0.0)
check("⚠️ AND A MISSING READING AT N=1 IS STILL ZERO, WHICH IS THE OPPOSITE OF "
"THE N>=2 RULE BELOW AND IS DELIBERATE. With one term the algebra has no "
"room for an unknown: |B| - |B| is 0 for every B, INCLUDING one nobody "
"read. Returning None here would park the shipped one-inverter install in "
"a permanent 'cannot tell' on a quantity that cannot be anything else",
circulating_w([None]) == 0.0)
print()
print(" a missing reading at N>=2 is UNKNOWN, and never zero")
check("⚠️ None, NOT 0.0. Zero would be this sensor reporting HEALTH FROM AN "
"ABSENCE - which is the 2026-09-02 failure itself, where a subsystem "
"stopped answering and its silence read as calm for 6 h 25 m",
circulating_w([None, 3000.0]) is None
and circulating_w([-3000.0, None]) is None
and circulating_w([None, None]) is None)
print()
print(" it is NON-NEGATIVE, including where floating point would say otherwise")
_fp = [circulating_w([0.1, 0.2, 0.3]), circulating_w([1e-9, 2e-9]),
circulating_w([-0.1, -0.2, -0.30000000000000004])]
print(f" {_fp}")
check("a same-sign roster never publishes a negative number. ⚠️ THIS GRADES "
"THE PROPERTY, NOT THE `max(0.0, ...)` GUARD THAT DEFENDS IT, and the "
"difference is stated so a mutation sweep does not report a gap that is "
"not one: for a same-sign roster the two sums accumulate the same "
"magnitudes in the same order and are bit-identical, so the guard is an "
"EQUIVALENT MUTANT - searched over 700 000 random rosters without a "
"single negative. circulation.py says so at the line",
all(v >= 0.0 for v in _fp))
print()
print("2. CFG-03: config.yaml is the source of truth and there is ONE copy")
# ⚠️ READ OFF THE FILE, NOT TRANSCRIBED. The Dockerfile does not put config.yaml
# in the image, so the running add-on cannot read it and `CIRC_OPTION_DEFAULTS`
# has to exist - which makes it a place to drift. This is the same pin
# test_maintenance.py puts on MAINT_OPTION_DEFAULTS, for the same reason.
import re as _re # noqa: E402
from pathlib import Path as _Path # noqa: E402
_cfg_txt = (_Path(__file__).resolve().parent / "config.yaml").read_text(encoding="utf-8")
# ⚠️ SPLIT ON THE TOP-LEVEL KEY, NOT ON THE WORD. "schema:" appears inside the
# CFG-03 comment above these very options ("options:, schema:, ..."), and
# splitting on the bare word cut the file in the middle of a comment - the
# options block then contained no options, the regex found none, and this pin
# compared an EMPTY dict against an empty expectation. That is MUTATION.md's
# third failure mode exactly: the assertion ran, the sentence was true, and the
# data could not reach the failure it names. Caught by the printed line.
_cfg_opts, _cfg_schema = _cfg_txt.split("\nschema:", 1)
_from_yaml = {k: int(v) for k, v in
_re.findall(r"^ (circulation_\w+):\s*(\d+)\s*$", _cfg_opts, _re.M)}
print(f" config.yaml options: {_from_yaml}")
print(f" CIRC_OPTION_DEFAULTS: {CIRC_OPTION_DEFAULTS}")
check("both options are declared in config.yaml's options: block and the "
"in-code mirror agrees with it key for key and value for value",
_from_yaml == CIRC_OPTION_DEFAULTS and len(_from_yaml) == 2)
_bounds = dict(_re.findall(r"^ (circulation_\w+):\s*int\((\d+),(\d+)\)\s*$",
_cfg_schema, _re.M)
and [(m[0], (int(m[1]), int(m[2]))) for m in
_re.findall(r"^ (circulation_\w+):\s*int\((\d+),(\d+)\)\s*$",
_cfg_schema, _re.M)])
print(f" schema bounds: {_bounds}")
check("...and BOTH have a schema range, so the Supervisor refuses an "
"out-of-range value rather than the add-on discovering it at runtime",
set(_bounds) == set(CIRC_OPTION_DEFAULTS))
check("⚠️ AND THE THRESHOLD'S FLOOR IS THE MEASURED KNEE, NOT A ROUND NUMBER. "
"On the TEST-19 rig the worst CONTINUOUS opposition a site where NOTHING "
"is faulty can produce is 5.0 s at 400 W, 10.0 s at 300 W, 40.0 s at "
"250 W and 505.0 s at 100 W. Below the knee, ordinary operation holds "
"this sensor over the threshold for MINUTES and no window shorter than "
"the 900 s billing quarter could tell that from a fault - so a "
"configurable value under 400 W would be a false-alarm generator, and "
"the schema does not offer one",
_bounds["circulation_w"][0] == 400)
check("...and the window's floor is the firmware's own veto window, "
"`inverter.LINK_STALE_S`, because below it a unit still executing a "
"stale share is a LINK fault the add-on already handles by dropping it "
"from the split - and its ceiling is the 900 s quarter that bills the "
"capacity tariff, inside which this must be caught and acted on",
_bounds["circulation_hold_s"] == (int(LINK_STALE_S), 900))
check("the shipped defaults sit inside their own schema ranges",
all(_bounds[k][0] <= v <= _bounds[k][1]
for k, v in CIRC_OPTION_DEFAULTS.items()))
check("⚠️ AND NEITHER IS A ROUND NUMBER, WHICH IS AC 3 READ LITERALLY. 700 W "
"is 1.75x the measured knee - the headroom is bought for inverter B's "
"ramp and lag being PLACEHOLDERS until SPIKE-04 measures them - and 75 s "
"is 15x the worst legitimate episode and 2.5x the firmware veto. 60, 100, "
"120, 180, 300, 500 and 1000 are all values a guess would have landed on",
CIRC_OPTION_DEFAULTS["circulation_w"] not in (100, 500, 1000, 2000)
and CIRC_OPTION_DEFAULTS["circulation_hold_s"] not in (30, 60, 120, 180, 300))
print()
print("3. the trip: sustained opposition fires, the slew transient does not")
def circ_drive(series, cfg=None, step=5.0):
"""Feed a list of circulating figures, one per control cycle."""
wd = CirculationWatchdog(cfg or CirculationConfig())
at = None
for i, v in enumerate(series):
if wd.evaluate(v, T0 + timedelta(seconds=i * step)) and at is None:
at = i * step
return wd, at
_CFG = CirculationConfig()
_sustained, _at = circ_drive([2000.0] * 40)
check("2000 W of measured opposition, held, trips", _sustained.tripped)
check(f"...and at {_CFG.hold_s:.0f} s, not before - the window is a window, "
"not a delay somebody rounded", _at == _CFG.hold_s)
check("the reason gives the operator the figure, the peak and the duration, "
"and says in words why nothing else reported it",
"2000" in _sustained.reason and "75 s" in _sustained.reason
and "nets to zero" in _sustained.reason.lower())
print(f" e.g. {_sustained.reason!r}")
print()
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
# reversing cycle B's new number lands up to a whole control cycle after A's,
# both slew through zero behind a real ramp and a real lag, and between the two
# crossings they genuinely oppose. A guard that trips on that is a false-alarm
# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15,
# SAFETY-19).
#
# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED
# TO SAY OTHERWISE. It read "the worst single transient the rig can produce -
# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN
# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its
# own words). Three sweeps have now been run against that claim and no two agree:
#
# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W
# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W
# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W
#
# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different
# grids, and the last one measured WHY the spread is so wide: on its own grid the
# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and
# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model
# and the stagger set are TERMS of the number, and a worst case quoted without
# them is not a measurement. It is evidence that the claim cannot be adjudicated
# while the instrument is missing.
#
# So what this check now says is what it can support: 1700 W is LARGER than
# anything any of the three sweeps produced legitimately, and one control cycle
# of it does not trip. The committed, re-runnable version of the claim is
# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every
# pull request there and requires ZERO of ~840 non-faulty runs to trip.
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
_t1, _ = circ_drive(_one_cycle)
check("a single transient of 1700 W - larger than the worst peak any committed "
"sweep of the legitimate family has produced - lasting ONE control cycle "
"does not trip", not _t1.tripped)
_repeated = ([0.0] * 3 + [1700.0]) * 20
_t2, _ = circ_drive(_repeated)
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
"a load flapping across zero all afternoon. The window clears between "
"them, so nothing accumulates", not _t2.tripped)
_almost = [1500.0] * 14 + [0.0] + [1500.0] * 14
_t3, _ = circ_drive(_almost)
check("...and opposition that stands for 70 s, clears for one cycle, then "
"stands again for 70 s does NOT trip: 140 s of trouble, but never 75 s "
"CONTINUOUSLY, which is what the guard actually claims to measure",
not _t3.tripped)
_below = [_CFG.min_w] * 60
_t4, _ = circ_drive(_below)
check(f"...and exactly {_CFG.min_w:.0f} W forever does not trip - the "
"comparison is strictly greater, so the threshold is a threshold rather "
"than an off-by-one", not _t4.tripped)
_just_over, _at5 = circ_drive([_CFG.min_w + 1.0] * 40)
check("...while one watt above it does, held long enough. The pair is what "
"makes the boundary a measured line rather than a direction",
_just_over.tripped and _at5 == _CFG.hold_s)
print()
print(" a MISSING reading clears the window rather than accumulating it")
_gap, _ = circ_drive([2000.0] * 10 + [None] + [2000.0] * 10)
check("⚠️ SAFETY-02 MAKES THE SAME DECISION FOR A STALE METER, AND FOR THE SAME "
"REASON: a frozen figure held against a moving one manufactures a fault "
"in about a window's worth of cycles. 50 s over, one unknown, 50 s over "
"again is not 100 s of opposition", not _gap.tripped)
print()
print(" the latch: it does not re-evaluate, because the trip removes the evidence")
_latched, _ = circ_drive([2000.0] * 40 + [0.0] * 40)
check("still tripped after the opposition has gone - and the opposition ALWAYS "
"goes, because the trip commands 0 W everywhere and two inverters at zero "
"cannot oppose. A non-latching version would de-assert and start the "
"whole thing again, three cycles at a time, forever", _latched.tripped)
_r = CirculationWatchdog(_CFG, tripped=True, reason="restored from the store")
check("...and one restored as tripped stays tripped without being re-driven",
_r.evaluate(0.0, T0) and _r.reason == "restored from the store")
_r.reset()
check("...until it is reset, which is the operator's press and nothing else",
not _r.tripped and _r.reason == "" and _r.held_s(T0) == 0.0)
print()
print(" `opposing()` names the two sides, for the sentence the operator reads")
check("indices of who is discharging and who is charging",
opposing([-3000.0, 3000.0]) == ([1], [0]))
check("...and a unit idling below the floor is not named as a participant in "
"somebody else's loop", opposing([-3000.0, 3000.0, 3.0], min_w=350.0)
== ([1], [0]))
print()
print("4. END TO END, AND THIS IS AC 2 - the sensor is MEASURED, so it sees")
print(" circulation the allocator NEVER COMMANDED")
# ⚠️ THIS IS THE CHECK MUTATION.md's THIRD FAILURE MODE IS ABOUT, so it is
# built the way that section says to build one. The claim is "X differs from Y"
# - the MEASURED sensor differs from a sensor computed off the allocation
# record - and X and Y are ACTUALLY MADE DIFFERENT here, with the difference
# asserted as its own precondition and PRINTED, before anything is concluded
# from it. DEV-02 shipped a check comparing a number against itself; ALLOC-01
# shipped a generator that found 0 escapes where a wider one found 39 991.
from app.inverter import Inverter # noqa: E402
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~8 days out (2026-09-04) and
# the bench half of SPIKE-04 measures it.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
class _CircHass(_Hass):
"""A house at rest, and two inverters MEASURED to be fighting each other.
⚠️ THE MEETER READS ZERO AND THAT IS THE WHOLE POINT. grid = load - sum(B),
and with A at -3000 W and B at +3000 W the sum is zero, so P1 reads exactly
what an idle site reads. Every existing guard in this add-on is a consumer
of that number.
"""
def __init__(self, a_w=-3000.0, b_w=3000.0):
super().__init__()
self.a_w, self.b_w = a_w, b_w
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0,
"sensor.soc": 50.0, "sensor.soc_b": 50.0,
"sensor.batt": self.a_w, "sensor.batt_b": self.b_w}.get(entity)
def _two_inverter(hass, store, *, hold_s=1, min_w=700, **over):
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "gain": 0.6,
"circulation_w": min_w, "circulation_hold_s": hold_s,
"auto_start": True}
opts.update(over)
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=B_RATING_HYPOTHESIS, store=store)
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
c.inverters.append(b)
c.auto = True
for inv in c.inverters:
inv.dev_min = inv.dev_min if inv.dev_min is not None else -5000.0
inv.dev_max = inv.dev_max if inv.dev_max is not None else 5000.0
return c
async def _circ_e2e():
hass = _CircHass()
c = _two_inverter(hass, _MemStore())
seen = []
for _ in range(14):
await c.read_inputs()
await c.cycle()
seen.append(dict(circ=c.site_circulating_w, grid=c.grid,
target=c.target, alloc=c.allocation,
tripped=c.circ_wd.tripped))
await asyncio.sleep(0.25)
return c, hass, seen
_c, _chass, _seen = asyncio.run(_circ_e2e())
_first = _seen[0]
_alloc = _first["alloc"]
_shares = dict(_alloc.shares)
_meas = [inv.batt for inv in _c.inverters]
print(f" MEASURED per-inverter power : {dict(zip(_c.units, _meas))}")
print(f" ALLOCATION RECORD : ok={_alloc.ok} {_alloc.explain()}")
print(f" meter (P1) : {_first['grid']:+.0f} W")
print(f" sensor over MEASURED power : {circulating_w(_meas):.0f} W")
print(f" sensor over the RECORD : "
f"{circulating_w(list(_shares.values())):.0f} W")
# --- THE PRECONDITION, AS ITS OWN CHECK -------------------------------------
# ⚠️ MUTATION.md RULE 1. If a later edit makes the record and the measurement
# agree again, THIS goes red rather than the claim below going quietly vacuous.
check("PRECONDITION: the allocation record and the measured power are "
"ACTUALLY DIFFERENT in this fixture. The record is coherent - it is `ok`, "
"its shares carry one sign, and they sum to the site target - while the "
"hardware is measured at -3000 W and +3000 W. Without this line the two "
"claims below could both be true of a fixture in which nothing differed",
_alloc.ok is True
and len({(s > 0) - (s < 0) for s in _shares.values() if s}) <= 1
and sorted(_meas) == [-3000.0, 3000.0]
and set(_shares.values()) != set(_meas))
check("ALLOC-01's gate is SATISFIED here, and that is the point rather than an "
"accident: the allocator refused nothing, because it commanded nothing "
"wrong. `REFUSE_SIGN` cannot fire on a record whose shares agree in sign",
_alloc.refused is None)
check("⚠️ SO A SENSOR COMPUTED FROM THE ALLOCATION RECORD READS EXACTLY 0 W. "
"That is the cheap version of this card, and it is worth nothing: "
"ALLOC-01's sign-coherence gate already guarantees the record is "
"sign-coherent, so a record-derived sensor is zero BY CONSTRUCTION and "
"can only ever confirm that the allocator agrees with itself",
circulating_w(list(_shares.values())) == 0.0)
check("⚠️ AND THE SHIPPED SENSOR READS 6000 W ON THE SAME CYCLE, because it is "
"derived from `Inverter.batt` - each unit's OWN measurement. This is the "
"whole card: it detects circulation THE ALLOCATOR NEVER COMMANDED - a "
"unit ignoring or lagging its share, a stale share still being executed, "
"or HW-01's power stage running its last instruction for 6 h 25 m with "
"the comms dead and the record impeccable throughout",
_c.site_circulating_w == 6000.0)
check("...and the METER READS ZERO through all of it, which is why no existing "
"guard fires. SAFETY-02 gates on abs(grid_w) > 300 W and that gate never "
"opens; the peak guard and the capacity tariff both bill grid exchange",
_first["grid"] == 0.0
and not any(i.sign_wd.tripped for i in _c.inverters))
print()
print(" ...and the trip ACTS: a site-wide stop, not a notification")
check("the watchdog tripped", _c.circ_wd.tripped)
check("the site is commanded 0 W", _c.target == 0.0)
check("...and 0 W actually reached BOTH inverters, not just the one somebody "
"decided was to blame",
_chass.writes and _chass.writes[-1] == 0.0
and all(i.target == 0.0 for i in _c.inverters))
check("...and it got there through the `circulation` claim, by name, rather "
"than by some other guard happening to stop the site",
"circulation" in _c.reason)
print(f" status line: {_c.reason!r}")
check("the status page says so in words, and says the meter cannot see it",
any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
and "meter cannot see" in ck["text"] for ck in _c.checks()))
check("the banner leads with it - above the sign failsafe, because a sign "
"disagreement is visible on every dashboard in the house and this is "
"visible nowhere at all",
_c.status_payload()["banner"] == messages.banner_circulating())
# UX-09: the red shadow banner no longer names the latch, so the latch's own
# red row must stay on the list in shadow mode, name it and say how it clears.
_c_mode = _c.o.get("shadow_mode")
_c.o["shadow_mode"] = "on"
check("in shadow mode the banner goes red and says a safety stop stays on, and "
"the circulating row is still red and says only starting control clears "
"it",
_c.status_payload()["level"] == "bad"
and "safety stop" in _c.status_payload()["banner"]
and any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
and "Only starting control clears this" in ck["text"]
for ck in _c.checks()))
_c.o["shadow_mode"] = _c_mode
_pub = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
"close": lambda _s: None})()
_c.publish()
print(f" published: circulation={_pub.get('circulation')} "
f"grid={_pub.get('grid')} status={_pub.get('status')}")
check("⚠️ AND IT IS PUBLISHED AS ITS OWN SENSOR BESIDE THE GRID FIGURE. "
"`grid 0 W` and `circulating 6000 W` on the same dashboard is the whole "
"phenomenon in two rows, and NO arithmetic on the grid figure alone can "
"ever produce the second one",
_pub.get("circulation") == 6000 and _pub.get("grid") == 0.0)
check("...and the controller-status sensor reports the failsafe state",
_pub.get("status") == "failsafe")
# ⚠️ AND THE UNKNOWN CASE, WHICH A SWEEP FOUND UNGRADED. A per-unit reading that
# did not arrive makes the site figure None, and `MqttPublisher.publish()` DROPS
# None values - so the entity keeps its last figure and the dashboard shows a
# stale number rather than a fresh zero. Publishing `round(x or 0.0)` instead
# survived the first mutation battery: it turns "I do not know" into "0 W", i.e.
# health manufactured from an absence, on the one surface an operator actually
# watches. Same defect as the sensor's own None rule, one layer out.
_c.inverters[1].batt = None
_pub_unknown = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub_unknown.update(
{k: x for k, x in v.items() if x is not None}),
"close": lambda _s: None})()
_c.publish()
print(f" with inverter-b's reading missing, publish() offers: "
f"circulation={_pub_unknown.get('circulation', '<omitted>')!r}")
check("⚠️ WITH ONE UNIT'S READING MISSING THE SENSOR IS NOT PUBLISHED AT ALL, "
"so the entity holds its last figure instead of being handed a 0 that "
"would read as health. `publish()` drops None, and this is what makes "
"that drop deliberate rather than incidental",
"circulation" not in _pub_unknown)
_c.inverters[1].batt = 3000.0
print()
print("5. AC 5: at ONE inverter the sensor is identically zero and NOTHING changes")
async def _n1():
hass = _CircHass(a_w=-3000.0, b_w=0.0)
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "gain": 0.6,
"circulation_w": 700, "circulation_hold_s": 1, "auto_start": True}
c = Controller(opts, hass, _MemStore(), _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c1 = asyncio.run(_n1())
print(f" roster={_c1.units} measured batt={_c1.inverters[0].batt:+.0f} W "
f"sensor={_c1.site_circulating_w}")
check("the shipped roster of one, with the single inverter measured at "
"-3000 W - a magnitude that would trip instantly at N=2 - reads exactly "
"0.0 W", _c1.site_circulating_w == 0.0)
check("...so the watchdog never trips, over 14 cycles at a 1 s window",
not _c1.circ_wd.tripped)
check("...no `circulation` claim is ever filed, so the arbitration is "
"byte-identical to what shipped", "circulation" not in _c1.reason)
check("...and the status page says n/a rather than claiming to watch something "
"that cannot happen - an 'armed' row for an identically-zero quantity is "
"a guard advertising work it is not doing",
any(ck.get("id") == "circulating" and ck["ok"]
and "not needed with one inverter" in ck["text"]
for ck in _c1.checks()))
_pub1 = {}
_c1.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub1.update(v),
"close": lambda _s: None})()
_c1.publish()
check("...and it publishes 0, not None: the value is KNOWN at N=1, and a "
"dashboard row reading 0 W is the honest report of it",
_pub1.get("circulation") == 0)
print()
print("6. SAFETY-34: the alarm survives a Home Assistant restart")
# ⚠️ THE LATCH IS NOT THE ALARM, AND THE 2026-09-02 OUTAGE IS WHY THAT SENTENCE
# IS HERE. `persistent_notification` is IN MEMORY: Home Assistant restarts and
# the card is gone while the fault runs on. That outage went unseen for 6 h 25 m
# with the detector correct throughout. So the durable half (the store) and the
# visible half (the notification) have to be joined, and this grades the join.
async def _circ_restart():
shared = _MemStore()
h1 = _CircHass()
a = _two_inverter(h1, shared)
for _ in range(14):
await a.read_inputs()
await a.cycle()
await asyncio.sleep(0.25)
# THE RESTART. Same store, brand new Controller and Hass - an ADD-ON
# restart. A Core-only restart keeps the process; graded below (SAFETY-34). The house has stopped
# circulating in the meantime, so nothing in the new process's own
# measurements could re-derive the trip.
h2 = _CircHass(a_w=0.0, b_w=0.0)
b = _two_inverter(h2, shared)
for _ in range(3):
await b.read_inputs()
await b.cycle()
await asyncio.sleep(0.05)
return a, b, shared, h1, h2
_a, _b, _shared, _h1, _h2 = asyncio.run(_circ_restart())
print(f" store after the trip: circ_tripped="
f"{_shared.data.get('circ_tripped')!r} "
f"circ_reason={str(_shared.data.get('circ_reason'))[:48]!r}...")
check("the trip is written to /data - the add-on's only durable volume - as a "
"flag AND a reason",
_shared.data.get("circ_tripped") is True
and "CIRCULATING_POWER" in str(_shared.data.get("circ_reason")))
check("⚠️ THE REBUILT CONTROLLER COMES UP TRIPPED, and its own measurements say "
"the site is quiet - 0 W on both units - so this can ONLY have come from "
"the store. A latch that lives in memory comes back armed, into the "
"fault, with no operator involved",
_b.circ_wd.tripped and _b.site_circulating_w == 0.0)
check("...carrying the reason with it, so the operator who has to decide "
"whether to clear it can read WHY",
"CIRCULATING_POWER" in _b.circ_wd.reason)
check("...and it is still commanding 0 W on the far side of the restart",
_b.target == 0.0 and "circulation" in _b.reason)
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
print(f" notifications raised: {len(_notes1)} before the restart, "
f"{len(_notes2)} after it")
check("the alarm was raised in the first process", len(_notes1) >= 1)
check("⚠️ AND RE-RAISED IN THE SECOND ONE. THIS IS THE CARD'S THIRD "
"DELIVERABLE. `persistent_notification` does not survive a Home Assistant "
"restart, so a latch alone would leave a stopped site with NOTHING in "
"front of the operator - the detector correct, the state file correct, "
"and the screen blank. That is how 2026-09-02 went unseen for 6 h 25 m",
len(_notes2) >= 1)
check("...under a fixed notification_id of its own, so a second episode "
"REPLACES the first rather than stacking, and it is not confused with "
"the frozen meter, the clamp or the saturated peak guard",
all(c[2]["notification_id"] == "goodwe_circulating_power"
for c in _notes1 + _notes2)
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
_msg = nth(_notes2, 0)[2]["message"]
def _says_three(m):
"""UX-08: the three things only this alarm can say, in its plain words
(messages.circulating): the meter cannot see it, control is stopped at
0 W, and if the grid does not move a person at the isolator is next."""
return ("meter cannot see this" in m and "Control stays stopped" in m
and "Every inverter is set to 0 W" in m and "isolator" in m
and "if the grid power does not change" in m)
check("...and the message tells the operator the three things only this alarm "
"can: that the meter cannot see it, that control has been stopped, and "
"that if the grid reading does not move afterwards one inverter is no "
"longer listening and a human at the isolator is the last actuator",
_says_three(_msg))
check("...and it does not raise the same card on every cycle - a per-PROCESS "
"flag, deliberately not persisted, is what makes 'has a human been shown "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
import app.main as _main_mod # noqa: E402
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_h3 = _CircHass(a_w=0.0, b_w=0.0)
_b.hass = _h3
asyncio.run(_b.check_circulating())
_early = [c for c in _h3.calls if c[0] == "persistent_notification"]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_b.check_circulating())
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
"storm on the event bus", len(_early) == 0)
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
"survives it, so after REANNOUNCE_S the SAME process re-asserts it",
len(_late) == 1
and _late[0][2]["notification_id"] == "goodwe_circulating_power")
print()
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing
#
# pos, neg = opposing([inv.batt for inv in self.inverters],
# min_w=self.circ_wd.cfg.min_w / 2.0)
#
# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes
# the "Right now X is discharging while Y charges" clause from the notification,
# and the whole suite stayed GREEN. The block above asserts three tokens -
# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter
# name, so the one sentence `circulation.py` calls "the first thing somebody
# woken by this alarm needs" and "impossible to reconstruct from the number
# alone" was graded by nothing at all.
#
# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER
# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()`
# call removed - goes red on four checks above, so this function runs. What
# nothing asserted was the ATTRIBUTION.
#
# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF
# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`;
# test_maintenance.py section 11 records what that costs - on a fixture whose
# names are the module's own constants, a check cannot tell the roster from the
# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here
# the names are an operator's, the two participants pull OPPOSITE ways so the
# sentence is not symmetric, and a third pack rests at 5 W. That makes three
# DIFFERENT mutations - the clause dropped, the two directions swapped, and the
# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both
# units the same, or one in which the direction did not matter, would see the
# first and neither of the others.
_CIRC_ROSTER = [
dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
batt_entity="sensor.batt_a", max_w=5000),
# ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600:
# inverter B does not physically exist and a datasheet is not a measurement.
dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b",
batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS),
# THE BYSTANDER. A third pack resting at standby draw, on the roster, read
# every cycle, and NOT a participant in anybody's loop.
dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c",
batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS),
]
_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER]
_REST_W = 5.0
class _NamedCircHass(_Hass):
"""Three named packs; the meter still reads exactly zero.
Same house as `_CircHass` - grid = load - sum(B) with two units cancelling -
with one extra pack idling at standby draw so that "who is named" and "who
is merely on the roster" are different questions.
"""
def __init__(self, a_w, b_w, c_w=_REST_W):
super().__init__()
self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w,
"sensor.batt_c": c_w}
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0, "sensor.soc_a": 50.0,
"sensor.soc_b": 50.0, "sensor.soc_c": 50.0,
**self.vals}.get(entity)
def _named_trio(hass, store, *, hold_s=1, min_w=700):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"max_w": 5000, "gain": 0.6, "auto_start": True,
"circulation_w": min_w, "circulation_hold_s": hold_s,
"inverters": _CIRC_ROSTER}
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
c.auto = True
for inv in c.inverters:
inv.dev_min, inv.dev_max = -5000.0, 5000.0
return c
async def _circ_named(a_w, b_w):
hass = _NamedCircHass(a_w, b_w)
c = _named_trio(hass, _MemStore())
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.25)
notes = [x for x in hass.calls
if x[0] == "persistent_notification"
and x[2].get("notification_id") == "goodwe_circulating_power"]
return c, notes
_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0))
_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0))
_msg_g = _notes_g[0][2]["message"] if _notes_g else ""
_msg_s = _notes_s[0][2]["message"] if _notes_s else ""
print(f" roster : {_cg.units}")
print(f" garage discharging, measured: "
f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}")
print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}")
print(f" shed discharging, measured: "
f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}")
print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}")
# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------
check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, "
f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim "
"about the roster and not about a constant that happens to equal it",
_cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3
and UNIT_A not in _CIRC_NAMES)
check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at "
"0 W, same magnitudes, and the two participants swapped. Without this the "
"direction claim below could be satisfied by a sentence that ignores the "
"measurement entirely",
[i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W]
and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W]
and _cg.grid == 0.0 and _cs.grid == 0.0)
check("PRECONDITION: both runs actually tripped and actually raised the card, "
"so an absent sentence below is an absent sentence and not an absent alarm",
_cg.circ_wd.tripped and _cs.circ_wd.tripped
and len(_notes_g) == 1 and len(_notes_s) == 1)
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
_WHO_G = "garage was giving power while shed was charging"
_WHO_S = "shed was giving power while garage was charging"
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
"needs and impossible to reconstruct from the watts alone, and until this "
"line the sentence could be deleted with all 26 files green",
_WHO_G in _msg_g)
check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a "
"single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded "
"sentence, satisfies the check above and fails this one. The operator is "
"being told which pack to walk to",
_WHO_S in _msg_s)
check("...and each run carries ONLY its own sentence - the mirrored alarm does "
"not also contain the other direction, so the clause is derived from this "
"cycle's measurement rather than assembled from the roster",
_WHO_S not in _msg_g and _WHO_G not in _msg_s)
check("...and the RESTING pack is not named. `cellar` is on the roster, is read "
"every cycle and is measured at 5 W of standby draw - `opposing()` is "
"called with a floor of half the trip threshold precisely so a pack idling "
"beside a 6 kW loop is not reported as one of its two participants",
"cellar" not in _msg_g and "cellar" not in _msg_s
and _cg.inverters[2].batt == _REST_W)
# UX-08: the watts and the window moved out of the card into the reason the
# log line and the status row carry; the card keeps the three things.
check("...and the sentence sits with the three things only this alarm can say, "
"while the watts and the window stay in the reason behind it",
all(_says_three(m) for m in (_msg_g, _msg_s))
and all("CIRCULATING_POWER" in c.circ_wd.reason
and "W measured going round" in c.circ_wd.reason
for c in (_cg, _cs)))
print()
print(" the operator's press is the only way out, and it clears BOTH halves")
async def _circ_clear():
shared = _MemStore()
c = _two_inverter(_CircHass(), shared)
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.25)
was = c.circ_wd.tripped
await c.handle_action("auto_toggle") # stop
await c.handle_action("auto_toggle") # start - the deliberate press
return c, shared, was
_cc, _cstore, _was = asyncio.run(_circ_clear())
check("it really was tripped before the press", _was)
check("a deliberate START clears the latch in memory and on disk together - "
"the condition ALWAYS goes away on its own, because the trip commands "
"0 W everywhere and two inverters at zero cannot oppose, so clearing on "
"the condition would clear on nothing",
not _cc.circ_wd.tripped
and _cstore.data.get("circ_tripped") is False
and _cstore.data.get("circ_reason") == "")
check("⚠️ AND THE 'HAS A HUMAN SEEN THIS' FLAG IS CLEARED WITH IT. Left "
"standing through a clear, the NEXT trip in this process would latch, "
"stop the site, and raise no notification at all",
_cc._circ_announced is False)
print()
print("=" * 72)
print("SAFETY-30: at two inverters the sign check is TWO questions, not one")
print("=" * 72)
# ⚠️ READ THIS BEFORE ADDING A CHECK HERE. Half of this card was already built
# and it is NOT the half below. DEV-02 shipped the PER-INSTANCE half: each
# `Inverter` owns its own `SignWatchdog`, latched under its own namespaced store
# keys, and a trip on B no longer latches A. test_inverter.py part 3 grades
# that; nothing here re-tests it.
#
# What is graded here is THE COMPARISON ITSELF. Before this card every unit's
# detector was handed `self.grid`. There is ONE P1, so at N>=2 that is a number
# this controller put on ONE actuator, graded against a measurement that answers
# for ALL of them - wrong in both directions: a FALSE trip when a sibling
# accounts for the meter, and a MISSED one when a sibling MASKS a unit that
# really is reversed. The fix is two comparisons, and they are two different
# faults: each unit against ITS OWN measured power, and the SUM of the standing
# commands against the meter.
#
# ⚠️ AND IT IS NOT SAFETY-31, WHICH IS THE SECTION ABOVE. `circulation.py`
# compares the units AGAINST EACH OTHER and catches two inverters pushing
# THROUGH each other, which nets to zero at the meter. This compares each unit
# against ITS OWN COMMAND and catches a unit not doing what it was told. They
# overlap in exactly one scenario - a unit reversing while its sibling pulls the
# other way - and part 4 below asserts what each one concludes there, because
# two guards that overlap without saying so is how a false-alarm generator gets
# built.
from app.control import Tuning as _Tuning # noqa: E402
from app.watchdog import ( # noqa: E402
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS, disagrees,
)
print()
print("1. ONE predicate, TWO second terms - and the trip sentence says which")
# ⚠️ THE ARITHMETIC IS UNCHANGED AND THAT IS THE POINT: `disagrees()` was not
# split in two. What moved is a PARAMETER NAME - `grid_w` became `against_w` -
# because what the command is opposed TO is now the caller's decision. A second
# copy of a safety predicate is one copy plus a future disagreement about what
# the check means (see `loop_is_following()`), so there is still exactly one.
_scfg = SignWatchdogConfig(min_w=300.0, hold_s=180.0)
_ucfg = SignWatchdogConfig(
min_w=float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]),
hold_s=float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"]))
check("the same call that means 'commanded -1000 W into a +1500 W import' "
"means 'commanded -1000 W while measuring +1500 W of discharge' when the "
"caller hands it a unit's own power - one function, two questions",
disagrees(-1000.0, 1500.0, True, _scfg) is True)
check("...and every gate still binds on the second term whatever it is: a "
"250 W reading does not qualify against a 300 W gate",
disagrees(-1000.0, 250.0, True, _scfg) is False)
check("...nor does a unit sitting at zero, which is a REFUSED command and not "
"a reversed one - the distinction the module docstring makes about a "
"battery declining a discharge, now read of the unit's own sensor",
disagrees(1500.0, 0.0, True, _ucfg) is False)
_wd_site = SignWatchdog(_scfg)
_wd_unit = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
for _t in range(0, 400, 5):
_wd_site.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
_wd_unit.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
print(f" site: {_wd_site.reason!r}")
print(f" unit: {_wd_unit.reason!r}")
check("⚠️ THE SITE INSTANCE'S SENTENCE IS BYTE-IDENTICAL TO THE ONE THAT "
"SHIPPED. It is what an operator reads on the status page, what goes in "
"the action log and what is persisted to /data/state.json, so it is "
"asserted against the literal rather than against a format string",
_wd_site.reason == (
"SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
"+1500 W for 180 s continuously (limits: both over 300 W, held over "
"180 s)"))
check("...and the per-unit instance names what it ACTUALLY compared instead. "
"'against a meter reading' there would be a false statement about which "
"instrument produced the evidence, on the one line somebody woken by "
"this alarm reads first",
AGAINST_OWN_POWER in _wd_unit.reason
and AGAINST_METER not in _wd_unit.reason
and "+1500 W" in _wd_unit.reason)
# ⚠️ `arm()` IS CONFIGURATION AND MUST NOT BE STATE. `Controller.cycle()` calls
# it on EVERY cycle, so a version that cleared `disagree_since` there would
# restart the window every cycle, never accumulate a hold, and leave the check
# permanently green with nothing saying so. Driven rather than argued: open a
# window, re-arm mid-episode, and see the window still standing.
_rearm = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
_rearm.evaluate(-1000.0, 1500.0, True, T0)
_rearm.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=5))
_rearm.arm(_ucfg, AGAINST_OWN_POWER)
check("re-arming mid-episode leaves the window exactly where it was",
_rearm.disagree_since == T0)
for _t in range(10, 200, 5):
_rearm.arm(_ucfg, AGAINST_OWN_POWER)
_rearm.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
check("...so a detector re-armed on every one of 38 cycles still trips at its "
"85 s hold, which is what says `arm()` did not quietly disarm it",
_rearm.tripped)
print()
print("2. CFG-03: config.yaml is the source of truth and there is ONE copy")
_sign_from_yaml = {k: int(v) for k, v in
_re.findall(r"^ ((?:unit|site)_sign_disagree_\w+):\s*(\d+)\s*$",
_cfg_opts, _re.M)}
print(f" config.yaml options: {_sign_from_yaml}")
print(f" SIGN_OPTION_DEFAULTS: {SIGN_OPTION_DEFAULTS}")
check("all four new options are declared in config.yaml's options: block and "
"the in-code mirror agrees key for key and value for value - the "
"Dockerfile does not put config.yaml in the image, so the running add-on "
"cannot read it and this is where the two can drift",
_sign_from_yaml == SIGN_OPTION_DEFAULTS and len(_sign_from_yaml) == 4)
_sign_bounds = {m[0]: (int(m[1]), int(m[2])) for m in
_re.findall(r"^ ((?:unit|site)_sign_disagree_\w+):"
r"\s*int\((\d+),(\d+)\)\s*$",
_cfg_schema, _re.M)}
print(f" schema bounds: {_sign_bounds}")
check("...and ALL FOUR have a schema range, so the Supervisor refuses an "
"out-of-range value rather than the add-on discovering it at runtime",
set(_sign_bounds) == set(SIGN_OPTION_DEFAULTS))
check("the shipped defaults sit inside their own schema ranges",
all(_sign_bounds[k][0] <= v <= _sign_bounds[k][1]
for k, v in SIGN_OPTION_DEFAULTS.items()))
check("⚠️ THE THRESHOLD IS `saturation_w / 2`, WHICH IS WHERE THE CONTROL LAW "
"ITSELF ALREADY DRAWS THE LINE. This check gates on BOTH sides, and the "
"two are OPPOSED, so the smallest command-versus-power gap it can ever "
"see is 2 x min_w. At 250 W that is exactly the 500 W at which "
"`compute()`'s own anti-windup test concludes the inverter is not "
"following and freezes the integrator - so this watchdog cannot fire on "
"a divergence the shipped loop calls ordinary tracking, and the two "
"guards meet at a number instead of overlapping in a band nobody drew",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"] * 2
== _Tuning().saturation_w)
check("...and the window clears its three bounds: above the firmware's own "
"30 s link veto (`inverter.LINK_STALE_S` - inside it, a unit not "
"executing its share is a LINK fault the add-on already answers by "
"dropping it from the split), and UNDER HALF the 180 s the site check "
"takes, because the per-unit conclusion needs nothing to net out before "
"it is complete",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"] > LINK_STALE_S
and SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"] < 180 / 2)
check("⚠️ AND NEITHER IS A ROUND NUMBER. 250 W is read off `saturation_w`; "
"85 s is 8.5x the worst legitimate opposition 1890 two-inverter runs "
"produce (10.0 s) and 2.8x the firmware veto. 60, 75, 90, 100, 120, 180 "
"and 300 are all values a guess would have landed on",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"]
not in (30, 60, 75, 90, 100, 120, 180, 300))
# ⚠️ AND THE SITE PAIR IS TWO DECISIONS, NOT ONE, WHICH IS WHY BOTH ARE PINNED.
# The THRESHOLD is carried from the shipped pair because its argument is about
# the meter's noise floor and the deadband - a property of ONE P1 and of
# `Tuning.deadband_w`, neither of which the roster touches. The WINDOW is NOT
# carried, because the transient it rejects is the loop crossing zero across a
# rail that IS the roster sum: measured at 110 s with one inverter and 215 s
# with two, on the same stimuli, against a 180 s hold. That is a carried number
# justified and a carried number refused, in the same pair.
check("the SITE threshold is the shipped one, carried - the noise argument "
"behind 300 W is about the meter and the deadband, and a second inverter "
"changes neither",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"] == 300)
check("⚠️ ...AND THE SITE WINDOW IS LONGER THAN THE SHIPPED 180 s, WHICH IS THE "
"MEASUREMENT REFUSING TO BE CARRIED. At two inverters the worst "
"legitimate opposition the rig produces is 215.0 s - reachable at "
"`gain: 0.05`, `slew_w: 50` and a 2.2 kW load reversal, all inside what "
"config.yaml accepts - so 180 s would have shipped a false trip on the "
"check with the best record in the safety table. 325 s is 1.51x that",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] > 215.0
and SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 215.0 * 2)
check("...and it is still inside the 900 s quarter that bills the capacity "
"tariff, and inside every episode the 2026-08-20 fault day contains - "
"its four clusters run 16, 30, 77 and 77 minutes",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 900
and SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] < 16 * 60)
check("...and it is deliberately not 300 or 360, the two round numbers next to "
"it",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"] not in (300, 360, 600, 900))
print()
print("3. AC 1: ONE unit drives the wrong way, and ONLY that unit trips")
# ⚠️ THE PLANT IS MODELLED AND SAYS SO. `_SignHass` 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
# opposition a real ramp and lag can produce is measured on admin/goodwe's
# two-master rig, over 1890 closed-loop runs, and recorded in
# DECISIONS/SAFETY-30-per-unit-sign.md. What this fixture is for is the
# ATTRIBUTION - which detector concludes what, from which instrument - and for
# that an instant plant is the honest model, because it removes the one
# mechanism that could make a unit look reversed without being reversed.
class _SignHass(_Hass):
"""Two inverters, one meter, and a chosen subset that does the OPPOSITE.
`reversed_units` are roster indices whose measured power is the NEGATION of
what this controller told them - HW-01's wedged power stage, an inverted
driver, a unit executing a stale share backwards. Everything else obeys.
"""
def __init__(self, load_w=2000.0, reversed_units=()):
super().__init__()
self.load_w = float(load_w)
self.reversed_units = set(reversed_units)
self.ctrl = None
def measured(self):
if self.ctrl is None:
return [0.0, 0.0]
out = []
for i, inv in enumerate(self.ctrl.inverters):
w = inv.last_write if inv.last_write is not None else inv.target
w = float(w or 0.0)
out.append(-w if i in self.reversed_units else w)
return out
async def number(self, entity, invert=False):
m = self.measured() + [0.0, 0.0]
return {"sensor.grid": self.load_w - sum(m[:2]),
"sensor.soc": 50.0, "sensor.soc_b": 50.0,
"sensor.batt": m[0], "sensor.batt_b": m[1]}.get(entity)
def _sign_site(hass, store=None, **over):
"""A two-inverter Controller, with SAFETY-31 deliberately given a long window.
⚠️ THE LONG `circulation_hold_s` IS ISOLATION, NOT A CLAIM THAT SAFETY-31 IS
SILENT HERE. A unit reversing while its sibling pulls the other way IS
circulating power and SAFETY-31 sees it - part 4 asserts exactly that, and
what each guard concludes about it. What must not happen is the SITE being
stopped by circulation while this section is trying to grade a PER-UNIT
attribution, because then every "only B is parked" check below would be
measuring the wrong guard.
⚠️ THE THREE THRESHOLD PAIRS ARE PAIRWISE DISTINCT IN BOTH COORDINATES,
AND THAT IS LOAD-BEARING RATHER THAN COSMETIC. This fixture is the only
place in the suite where `_arm_sign_watchdogs()` runs at a roster of two, so
it is the only place that can see WHICH pair reaches WHICH detector. The
first version of it set every hold to 1 s and two of the three magnitude
gates to 300 W; a detector armed with the shipped pair, or with the site
pair, or with the right magnitude and the wrong hold, then compared EQUAL
to the pair it was supposed to have, and the wiring check below would have
passed on three of those four arrangements. That is MUTATION.md's third
failure mode written into a fixture - a check comparing two things that
happen to be equal cannot see the difference it names - and the
precondition beside the check is what stops a later edit collapsing them
again without a red line.
Anyone changing a hold here: the PER-UNIT hold is what part 3's trip timing
is read against, and the SITE hold must still expire inside `_drive()`'s
window (14 cycles x 0.2 s), which part 5 depends on.
"""
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,
"sign_disagree_w": 300, "sign_disagree_s": 3,
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
"site_sign_disagree_w": 320, "site_sign_disagree_s": 2,
"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())
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=B_RATING_HYPOTHESIS, store=c.store)
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
c.inverters.append(b)
c.auto = True
for inv in c.inverters:
if inv.dev_min is None:
inv.dev_min, inv.dev_max = -5000.0, 5000.0
hass.ctrl = c
return c
async def _drive(c, n=14, dt=0.2, watch=None, pre=None):
"""Drive `n` real cycles. `watch` samples AFTER each cycle, `pre` BEFORE it.
⚠️ `pre` EXISTS BECAUSE `watch` ALONE CANNOT SEE WHAT A DETECTOR WAS HANDED,
AND SAFETY-39 IS HOW THAT WAS FOUND. `cycle()` evaluates the sign watchdogs
against THE STANDING COMMAND - the write from the PREVIOUS cycle - and then
issues a new one. So `watch`'s `last_write` is the command AFTER the
detector ran, and pairing it with `c.grid` builds a pair the detector never
saw. While `slew_w` was 1000 the loop was at its rail within two cycles and
consecutive commands were IDENTICAL, so the off-by-one was invisible; at a
rail-following slew the loop ramps and the two numbers differ by one step.
That is a fault in this harness, not in the code it grades - and a check
that compares an operator-facing sentence against the wrong cycle is
precisely the "the data could not reach the failure the text names" defect
MUTATION.md keeps finding. `pre` reads the operands the detector will use.
"""
seen = []
for _ in range(n):
await c.read_inputs()
before = {} if pre is None else pre(c)
await c.cycle()
if watch is not None:
seen.append({**before, **watch(c)})
await asyncio.sleep(dt)
return seen
async def _one_reversed():
hass = _SignHass(load_w=2000.0, reversed_units={1})
c = _sign_site(hass)
seen = await _drive(c, watch=lambda c: dict(
grid=c.grid,
stood=[i.last_write if i.last_write is not None else i.target
for i in c.inverters],
meas=[i.batt for i in c.inverters],
a=c.inverters[0].sign_wd.tripped, b=c.inverters[1].sign_wd.tripped,
site=c.site_sign_wd.tripped))
return c, hass, seen
_c30, _h30, _s30 = asyncio.run(_one_reversed())
_a30, _b30 = _c30.inverters
# ⚠️ THE WIRING PIN FOR AC 5, AND IT IS A DIFFERENT CLAIM FROM EVERY
# BEHAVIOURAL CHECK IN THIS SECTION. The rest of part 3 grades what the
# detectors CONCLUDED; this grades WHICH THRESHOLDS THEY WERE HANDED, and the
# gap between the two was live. Replacing
# `arm(self.sign_cfg if one else self.unit_sign_cfg, ...)` with
# `arm(self.sign_cfg, ...)` in `Controller._arm_sign_watchdogs()` throws the
# re-derived per-unit pair away and arms every unit with SAFETY-02's
# meter-facing one - and left ALL 19 FILES GREEN. Nothing at a roster of two
# read `inv.sign_wd.cfg` at all; the one place that does is part 7's N=1 arm,
# which that edit PRESERVES.
#
# WHY THE TRIPS BELOW CANNOT CATCH IT, said here because "the suite is green"
# and "nothing was wrong" are the two readings this comment exists to separate.
# B is reversed at ~2000 W - far over 250 W and far over 300 W - and every hold
# in this fixture is short enough to expire inside `_drive()`. A fault that is
# big enough and long enough to trip under BOTH pairs is evidence about
# neither. Only an equality on the pair itself can tell them apart.
#
# WHAT THE SURVIVOR COSTS IN THE FIELD, at config.yaml's own defaults: the
# per-unit check runs at `sign_disagree_s` 180 s instead of the re-derived 85 s
# - 2.1x slower to park a reversed inverter - and its gate moves from 250 W to
# 300 W, so a unit reversing a share between the two becomes invisible, while
# watchdog.SIGN_OPTION_DEFAULTS, config.yaml and DECISIONS section 3 all go on
# describing a pair the running code does not use.
_UNIT_PAIR = (250.0, 1.0) # `unit_sign_disagree_w` / `_s` - one per unit
_SITE_PAIR = (320.0, 2.0) # `site_sign_disagree_w` / `_s` - one per site
_SHIPPED_PAIR = (300.0, 3.0) # `sign_disagree_w` / `_s` - N=1 only
_armed = {inv.name: (inv.sign_wd.cfg.min_w, inv.sign_wd.cfg.hold_s)
for inv in _c30.inverters}
_armed_site = (_c30.site_sign_wd.cfg.min_w, _c30.site_sign_wd.cfg.hold_s)
print(f" armed per unit: {_armed}")
print(f" armed site: {_armed_site} "
f"shipped pair, wanted by NOBODY here: {_SHIPPED_PAIR}")
check("PRECONDITION: the three pairs are pairwise distinct IN BOTH "
"COORDINATES, so an equality below can fail on the magnitude alone, on "
"the hold alone, or on both. This fixture originally gave all three the "
"same 1 s hold and two of them the same 300 W gate, and a mis-wiring "
"that delivered the right magnitude with the wrong hold would then have "
"compared EQUAL to the pair it was supposed to have",
len({_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR}) == 3
and len({p[0] for p in (_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR)}) == 3
and len({p[1] for p in (_UNIT_PAIR, _SITE_PAIR, _SHIPPED_PAIR)}) == 3)
check("PRECONDITION: and all three are pairs this Controller actually holds, "
"so the literals below are the fixture's own numbers and not three "
"constants that agree with nothing. In particular the shipped pair is "
"still constructed and still a reachable mis-wiring target",
(_c30.unit_sign_cfg.min_w, _c30.unit_sign_cfg.hold_s) == _UNIT_PAIR
and (_c30.site_sign_cfg.min_w, _c30.site_sign_cfg.hold_s) == _SITE_PAIR
and (_c30.sign_cfg.min_w, _c30.sign_cfg.hold_s) == _SHIPPED_PAIR)
check("⚠️ AC 5 WIRING. EVERY per-unit detector on this roster of two is armed "
"with the PER-UNIT pair - 250 W / 1 s here, `unit_sign_disagree_w` and "
"`unit_sign_disagree_s` in the field. Graded against LITERALS rather "
"than against `_c30.unit_sign_cfg`, which cannot tell 'the per-unit "
"pair' from 'whatever this Controller was handed'. This is the line that "
"goes red when `_arm_sign_watchdogs()` stops choosing between the two "
"pairs and hands every unit the site-facing one",
len(_armed) == 2 and all(p == _UNIT_PAIR for p in _armed.values()))
check("...and the SITE detector is armed with the SITE pair - 320 W / 2 s "
"here - which differs from the per-unit pair AND from the shipped pair "
"in both coordinates, so this equality is not carried by a number the "
"fixture happens to hold twice",
_armed_site == _SITE_PAIR)
check("...and each detector is pointed at its own INSTRUMENT as well as at its "
"own thresholds: the units at their own 0x0518 power, the site at the "
"meter. They are two arguments of one call and a pin on one is not a pin "
"on the other",
all(inv.sign_wd.against == AGAINST_OWN_POWER for inv in _c30.inverters)
and _c30.site_sign_wd.against == AGAINST_METER)
_mid = _s30[3]
print(f" cycle 3: stood {[round(x) for x in _mid['stood']]} "
f"measured {[round(x) for x in _mid['meas']]} "
f"meter {_mid['grid']:+.0f} W")
check("PRECONDITION: the stimulus is not vacuous - inverter B really is doing "
"the OPPOSITE of what it was told, and inverter A really is obeying. "
"Without this line every claim below could be true of a fixture in which "
"nothing was reversed",
_mid["stood"][1] * _mid["meas"][1] < 0
and abs(_mid["meas"][1]) > 250
and _mid["stood"][0] * _mid["meas"][0] > 0)
check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The evidence "
"is B's own 0x0518 power against B's own share, so it is a fact about B "
"and nothing about A - which is what makes parking one of them the right "
"remedy rather than an attribution nobody measured",
_b30.sign_wd.tripped and not _a30.sign_wd.tripped)
check("...and the reason names the instrument, so the operator is sent to the "
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
print(f" {_b30.sign_wd.reason!r}")
# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's
# lesson and 9a's. After the trip B's share is 0 W and its measured power
# follows it down, so a fixed index reads the REMEDY instead of the evidence.
# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off
# the objects - and not off the fixture's constants, so the check cannot be
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
_preb30 = [x for x in _s30 if not x["b"]]
_midb30 = nth(_preb30, -1, "no cycle before B latched")
print(f" last cycle before B latched: B stood "
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
"SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand "
"order at main.py:1620; that call site is armed only at N>=2 and part 9c "
"asserts it is ABSENT at a roster of one, so on the shipped install the "
"PER-UNIT banner is the only one there is - and its call site is a "
"different line. Swapping the first two arguments at main.py:1560 - "
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is "
"a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` "
"is symmetric in those two arguments (`commanded_w * against_w < 0` with "
"an abs() gate on both). Nothing behavioural can see it and the full "
"21-file suite stayed green. What it changes is the SENTENCE: 'commanded "
"-1000 W against its own measured power +1000 W', the exact opposite of "
"which instrument read what, on the line somebody woken by this alarm "
"reads first - and it is persisted to `sign_reason` and re-read after a "
"restart. Graded on the CONTIGUOUS LITERAL with each value in its own "
"slot, per MUTATION.md habit 14: an `in` test on a number grades "
"PRESENCE, never ARRANGEMENT",
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason)
check("PRECONDITION for the line above, and without it that check is "
"decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
"SIGN, so transposing them really does produce a different string. A "
"cycle where B's share and B's measured power were equal, or where "
"either was 0 W, would make the swapped sentence identical to the "
"correct one and the assertion could not reach the failure it names",
_midb30["stood"][1] != _midb30["meas"][1]
and _midb30["stood"][1] * _midb30["meas"][1] < 0
and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}")
check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, "
"because `sign_reason` is the persisted copy of this string. The swap "
"survives the container restart the same way the latch does",
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
f"{_midb30['meas'][1]:+.0f} W"
in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), "")))
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
# for the site winner. That is the DEV-02 split working, and a check that
# grepped the site line for "failsafe" would go green only while the roster was
# one. The per-unit record is where the pin is stated, and it names its source.
_bplan = [pl for pl in (_c30.allocation.plans if _c30.allocation else ())
if pl.unit == "inverter-b"]
print(f" allocation: {_c30.allocation.explain()}")
print(f" inverter-b plan: {_bplan[0].why if _bplan else '(none)'}")
check("...B is commanded 0 W by its OWN `failsafe` claim, named in the "
"allocation record, rather than by some other guard happening to stop it",
_b30.target == 0.0 and _bplan and "failsafe" in _bplan[0].why)
check("...and A IS STILL COMMANDING. One inverter in trouble stops that "
"inverter; the site goes on running on the one that works, which is what "
"a UNIT-scoped claim buys and what a site-wide stop would have thrown "
"away", _a30.target != 0.0)
check("...the latch is under B's OWN namespaced key, and A's is untouched",
_c30.store.data.get(unit_key("inverter-b", "sign_tripped")) is True
and _c30.store.data.get(TRIPPED_KEY) is False)
print()
print("4. AC 2: and it is caught EVEN THOUGH THE METER SAYS EVERYTHING IS FINE")
# ⚠️ THIS IS THE CHECK THE WHOLE CARD IS FOR, so it is built the way MUTATION.md
# says to build one: the claim is "the new comparison sees what the old one
# structurally cannot", and the OLD comparison is RUN, on the same samples, and
# its blindness is asserted as its own precondition and PRINTED - not argued.
_old_view = [disagrees(s["stood"][1], s["grid"], True, _scfg) for s in _s30]
_site_view = [disagrees(sum(s["stood"]), s["grid"], True, _scfg) for s in _s30]
print(f" the SHIPPED comparison (B's command vs the meter) was true on "
f"{sum(_old_view)} of {len(_old_view)} cycles")
print(f" the SITE comparison (sum of commands vs the meter) on "
f"{sum(_site_view)} of {len(_site_view)}")
check("⚠️ THE COMPARISON THAT SHIPPED IS FALSE ON EVERY SINGLE CYCLE HERE. B is "
"commanded to discharge and the house is importing, so B's command and "
"the meter AGREE all the way through - the meter cannot tell that the "
"watts arriving are inverter A's while B does the opposite. This is not "
"a threshold set too high: the summation destroys the evidence and no "
"tuning recovers it",
not any(_old_view))
check("...and the SITE comparison is blind for the same reason, which is why "
"it is kept as a SEPARATE question rather than offered as the fix",
not any(_site_view))
check("...while the shipped SITE watchdog stayed correctly silent, so nothing "
"here is a site-level trip wearing a per-unit label",
not _c30.site_sign_wd.tripped
and _c30.store.data.get("site_sign_tripped") is not True)
check("⚠️ AND SAFETY-31 SEES SOMETHING TOO, WHICH IS NOT A DUPLICATE AND IS "
"SAID HERE RATHER THAN LEFT FOR SOMEBODY TO DISCOVER. A unit reversing "
"while its sibling pulls the other way IS power going round a loop, so "
"`circulating_w()` is over its threshold on this fixture - it is only "
"the long window this fixture gives it that keeps it from stopping the "
"site. The two guards CONCLUDE different things: circulating power is "
"defined over the roster and names no offender, while this one names B "
"and parks B. Neither is the other with a different threshold",
max(circulating_w(s["meas"]) for s in _s30) > _c30.circ_wd.cfg.min_w
and not _c30.circ_wd.tripped
and _c30.site_circulating_w == 0.0)
print(f" circulating power over the episode: "
f"{[round(circulating_w(s['meas'])) for s in _s30]}")
check("...and it falls back to ZERO the moment B is parked, which is the "
"remedy working rather than the sensor going quiet: a unit held at 0 W "
"cannot be half of a loop. It is asserted on the EPISODE rather than on "
"the end state for exactly that reason",
_s30[-1]["meas"][1] == 0.0)
print()
print("5. AC 3: the SITE question survives, separately, and catches whole-site")
print(" reversal - which no per-unit check can see")
# ⚠️ THE FAULT HERE IS THE ONE THE 2026-08-20 CAPTURE CONTAINS, and it is
# INVISIBLE to every per-unit comparison by construction: both inverters are
# doing EXACTLY what they were told. What is wrong is the number they were told.
# A negative gain is the minimal faithful model of it - an inverted polarity, a
# wound-up integrator, a strategy on the wrong side of the meter - and it is not
# a reachable configuration (`gain` is float(0.05,1.0)), which is the point: it
# stands in for a code regression, which is the case a check can hold the line on.
async def _site_reversed():
hass = _SignHass(load_w=2000.0, reversed_units=())
c = _sign_site(hass, gain=-0.6)
seen = await _drive(c, watch=lambda c: dict(
grid=c.grid, site=c.site_sign_wd.tripped,
stood=[i.last_write if i.last_write is not None else i.target
for i in c.inverters],
meas=[i.batt for i in c.inverters]))
return c, hass, seen
_c31, _h31, _s31 = asyncio.run(_site_reversed())
# ⚠️ THE LAST SAMPLE BEFORE THE TRIP, NOT A FIXED INDEX. After the trip every
# command is 0 W by design, so a fixed index reads the REMEDY rather than the
# fault and the precondition below would be asserting that a stopped site is
# stopped. This is `Run.p2p_between`'s lesson from the rig, in a unit test: name
# the window rather than inferring it.
_pre31 = [x for x in _s31 if not x["site"]]
_mid31 = nth(_pre31, -1, "no cycle before the trip")
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _mid31['stood']]} "
f"measured {[round(x) for x in _mid31['meas']]} "
f"meter {_mid31['grid']:+.0f} W")
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is still charging into "
"an importing house. Without this the site trip below could be a "
"per-unit fault in disguise",
present(_mid31)
and all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"]))
and sum(_mid31["stood"]) * _mid31["grid"] < 0)
check("⚠️ AC 3. THE SITE WATCHDOG TRIPS AND NOT ONE PER-UNIT WATCHDOG DOES. "
"That asymmetry is the card: the fault is in the number the site was "
"asking for, which no single inverter owns, and every unit is innocent "
"of it",
_c31.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c31.inverters))
print(f" {_c31.site_sign_wd.reason!r}")
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
"rather than by some other guard happening to stop it",
_c31.target == 0.0 and "sign_site" in _c31.reason
and all(i.target == 0.0 for i in _c31.inverters))
check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
"and NOT mirrored onto the pre-DEV-02 `sign_tripped`, which is the "
"rollback view of a UNIT latch. A downgraded add-on drives exactly one "
"actuator; handing it a latch taken on evidence about a roster it cannot "
"have would be a trip it can never explain",
_c31.store.data.get("site_sign_tripped") is True
and "SIGN_DISAGREEMENT" in str(_c31.store.data.get("site_sign_reason"))
and _c31.store.data.get("sign_tripped") is not True)
check("the status page carries a SITE row of its own, and the banner says the "
"site's TOTAL command opposed the meter rather than naming an inverter - "
"naming one would send an operator to swap the wrong box",
any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"] and "every inverter" in ck["text"]
for ck in _c31.checks())
and "Total power" in _c31.status_payload()["banner"]
and "inverter-" not in _c31.status_payload()["banner"])
_c31_mode = _c31.o.get("shadow_mode")
_c31.o["shadow_mode"] = "on"
check("UX-09: in shadow mode the banner goes red and says a safety stop stays "
"on, and the site row is still red and says only starting control "
"clears it",
_c31.status_payload()["level"] == "bad"
and "safety stop" in _c31.status_payload()["banner"]
and any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"]
and "Only starting control clears this" in ck["text"]
for ck in _c31.checks()))
_c31.o["shadow_mode"] = _c31_mode
_pub30 = {}
_c31.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub30.update(v),
"close": lambda _s: None})()
_c31.publish()
check("...and the controller-status sensor reports the failsafe state, so a "
"site parked at 0 W cannot publish 'running'",
_pub30.get("status") == "failsafe")
# The latch, and the one way out of it.
_was31 = _c31.site_sign_wd.tripped
asyncio.run(_c31.handle_action("auto_toggle")) # stop
check("pressing STOP clears nothing - the condition always goes away on its "
"own, because the trip commands 0 W and a site at zero cannot oppose "
"anything", _c31.site_sign_wd.tripped and _was31)
asyncio.run(_c31.handle_action("auto_toggle")) # the deliberate start
check("pressing START clears the latch in memory and on disk together, which "
"is the same deliberate human act that clears every other latch here",
not _c31.site_sign_wd.tripped
and _c31.store.data.get("site_sign_tripped") is False
and _c31.store.data.get("site_sign_reason") == "")
async def _site_restart(store):
hass = _SignHass(load_w=2000.0)
c = _sign_site(hass, store=store, gain=-0.6)
await _drive(c, n=3, dt=0.05)
return c
_store31 = _MemStore()
_store31.data["site_sign_tripped"] = True
_store31.data["site_sign_reason"] = "SIGN_DISAGREEMENT: (from before the restart)"
_c31r = asyncio.run(_site_restart(_store31))
check("⚠️ AND IT SURVIVES A RESTART, which is the only thing that makes it a "
"latch rather than a pause. 'Restart the add-on' is the first thing an "
"operator staring at a FAILSAFE banner tries, and a site that came back "
"armed would drive straight into the fault it had just stopped for",
_c31r.site_sign_wd.tripped and _c31r.target == 0.0
and "sign_site" in _c31r.reason)
print()
print("6. AC 4: two inverters commanded OPPOSITE SIGNS by a legitimate")
print(" allocation, and nothing trips")
# ⚠️ THIS IS THE FALSE TRIP THE CARD OPENS WITH, AND IT IS DRIVEN THROUGH THE
# SHIPPED PREDICATE RATHER THAN DESCRIBED. Inverter A charging 1500 W while
# inverter B discharges 1500 W into a 1500 W import is an ordinary two-inverter
# arrangement - a per-unit maintenance charge on A while the loop drives B, or
# SAFETY-25's floor pinning one unit. Nothing is faulty. Before this card the
# meter convicted A on it.
_A_CMD, _B_CMD, _METER = -1500.0, 1500.0, 1500.0
_wd_a = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
_wd_b = SignWatchdog(_ucfg, against=AGAINST_OWN_POWER)
_sitecfg = SignWatchdogConfig(
min_w=float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]),
hold_s=float(SIGN_OPTION_DEFAULTS["site_sign_disagree_s"]))
_wd_s = SignWatchdog(_sitecfg)
for _t in range(0, 1200, 5):
_wd_a.evaluate(_A_CMD, _A_CMD, True, T0 + timedelta(seconds=_t))
_wd_b.evaluate(_B_CMD, _B_CMD, True, T0 + timedelta(seconds=_t))
_wd_s.evaluate(_A_CMD + _B_CMD, _METER, True, T0 + timedelta(seconds=_t))
print(f" A commanded {_A_CMD:+.0f} W and measured {_A_CMD:+.0f} W; "
f"B {_B_CMD:+.0f} / {_B_CMD:+.0f}; meter {_METER:+.0f} W")
check("PRECONDITION: THE COMPARISON THAT SHIPPED CONVICTS INVERTER A HERE, on "
"every one of these samples. A is commanded to CHARGE while the meter "
"reads IMPORT - opposed, both far over 300 W, and it would have held for "
"20 minutes. That is the false trip, and it is asserted rather than "
"asserted about",
disagrees(_A_CMD, _METER, True, _scfg) is True)
check("⚠️ AC 4. NEITHER UNIT TRIPS UNDER THE NEW COMPARISON, over 20 minutes at "
"an 85 s hold. Each one is doing exactly what it was told, and its own "
"power sensor is the instrument that can say so",
not _wd_a.tripped and not _wd_b.tripped)
check("...and the SITE watchdog does not trip either, because the shares SUM "
"to zero and a 0 W total is not a wrong-direction command - the "
"magnitude gate that has always been there, now applied to the quantity "
"it belongs on", not _wd_s.tripped and _wd_s.disagree_since is None)
# ⚠️ AND THE COUNTERFACTUAL, BECAUSE "nothing tripped" IS ALSO WHAT A DISARMED
# GUARD REPORTS. Same site, same meter, but the shares no longer cancel: a NET
# -1600 W of charge into a +1500 W import is the site genuinely driving the
# wrong way, and it must trip. Without this line the check above passes
# identically against a site watchdog that was never wired to anything.
_wd_s_net = SignWatchdog(_sitecfg)
for _t in range(0, 600, 5):
_wd_s_net.evaluate(-1600.0, _METER, True, T0 + timedelta(seconds=_t))
check("...and the site question is not thereby disarmed: the same site, "
"commanded a NET -1600 W into the same import, DOES trip",
_wd_s_net.tripped)
print()
print("7. AC 8: at ONE inverter this card changes NOTHING, and 'nothing' is")
print(" asserted rather than assumed")
async def _n1_sign():
hass = _SignHass(load_w=2000.0)
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,
"sign_disagree_w": 300, "sign_disagree_s": 1,
"auto_start": True}
c = Controller(opts, hass, _MemStore(), _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
hass.ctrl = c
# ⚠️ A CALL COUNTER, NOT AN OBSERVATION OF THE DETECTOR'S STATE, AND A
# MUTATION IS WHY. The first version of the check below read
# `site_sign_wd.disagree_since is None` - and arming the site detector at
# N=1 as well left this suite GREEN, because the unit detector trips first
# at its 1 s hold, parks the inverter, and the site's standing command is
# then 0 W: the site predicate is false, so its window is never opened and
# "never evaluated" reads identically to "evaluated and found nothing". That
# is MUTATION.md's third failure mode - the assertion ran, the sentence was
# true, and the data could not reach the failure it names. A counter can
# only be zero for the one reason.
_n1_calls = []
_orig_eval = c.site_sign_wd.evaluate
def _spy(*a, **kw):
_n1_calls.append(a)
return _orig_eval(*a, **kw)
c.site_sign_wd.evaluate = _spy
# ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS.
# `_n1_calls` records what was HANDED to a detector, so grading the banner
# against it would compare the swap against itself - the DEV-02 defect.
# `last_write` and `c.grid` are read off the objects the plant moved.
# ⚠️ `stood` COMES FROM `pre`, i.e. BEFORE `cycle()` RUNS (SAFETY-39). It is
# the STANDING command the sign watchdog is evaluated against; taking it
# after the cycle would pair this cycle's meter with next cycle's command
# and grade the banner against a pair that never existed. See `_drive`.
# `n` is 24 rather than 12 because at the shipped rail-following `slew_w`
# the loop needs nine cycles to reach a 2000 W rail instead of two.
seen = await _drive(c, n=24, dt=0.2, pre=lambda c: dict(
stood=(c.inverters[0].last_write
if c.inverters[0].last_write is not None
else c.inverters[0].target)),
watch=lambda c: dict(
grid=c.grid,
tripped=c.inverters[0].sign_wd.tripped))
return c, _n1_calls, seen
_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign())
_inv1 = _c1s.inverters[0]
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
check("the single unit is graded against THE METER, at the SHIPPED thresholds "
"- §6.2's identity, and the whole compatibility promise of this card. "
"The unit's own power is not consulted, and `unit_sign_disagree_*` does "
"nothing on a one-inverter install",
_inv1.sign_wd.against == AGAINST_METER
and (_inv1.sign_wd.cfg.min_w, _inv1.sign_wd.cfg.hold_s) == (300.0, 1.0))
check("...it trips, on the same evidence and through the same UNIT `failsafe` "
"claim as before, commanding 0 W",
_inv1.sign_wd.tripped and _c1s.target == 0.0
and "failsafe" in _c1s.reason and "sign_site" not in _c1s.reason)
check("...the reason sentence still says 'a meter reading', word for word - it "
"is what an operator reads, what the action log carries and what is "
"persisted, so it is graded on the literal",
"against a meter reading" in _inv1.sign_wd.reason)
# THE CYCLE THE LATCH HAPPENED ON, named rather than indexed: afterwards the
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
# remedy. Both operands come off the controller, not off the fixture's options.
#
# ⚠️ IT IS THE FIRST *TRIPPED* SAMPLE, AND IT USED TO BE THE LAST UN-TRIPPED ONE
# (SAFETY-39). The detector is evaluated INSIDE `cycle()` against the STANDING
# command - the write from the previous cycle - and against the meter read at
# the top of THIS one, and it latches on the cycle whose pair crosses the hold.
# So the operands the sentence quotes belong to the LATCHING cycle, not to the
# one before it. `stood` is now sampled by `_drive`'s `pre` hook, before
# `cycle()` overwrites it with the failsafe's 0 W, which is what makes that
# cycle readable at all - and it is why the old form had to reach back a cycle.
# The two forms agreed while `slew_w` was 1000 and the loop sat at its rail with
# consecutive commands IDENTICAL; at a rail-following slew the loop ramps and
# they differ by exactly one step, which is how this was found.
_first1s = [x for x in _seen1s if x["tripped"]]
_mid1s = nth(_first1s, 0, "no tripped cycle")
print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, "
f"meter {_mid1s['grid']:+.0f} W")
print(f" {_inv1.sign_wd.reason!r}")
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
"zero-byte defect part 9a closed at main.py:1620, one call site over at "
"main.py:1560 - and this is the one a one-inverter install actually "
"reaches, since the site detector is never armed here (asserted below). "
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` "
"changes no trip decision - `disagrees()` is symmetric in those two "
"arguments - and inverts the banner to 'commanded +4000 W against a "
"meter reading -2000 W': the loop is standing at -2000 W of charge INTO "
"a +4000 W import and the latching banner says the opposite. The "
"substring check above passes under the swap, because both numbers are "
"still in the sentence; only the ORDERED PAIR can tell the slots apart "
"(MUTATION.md habit 14)",
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason)
check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
"SIGN on that cycle, so a transposition genuinely produces a different "
"string. Equal or zero operands would make the swapped banner identical "
"and the check above vacuous",
_mid1s["stood"] != _mid1s["grid"]
and _mid1s["stood"] * _mid1s["grid"] < 0
and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}")
check("...and the persisted copy carries the same ordered pair, under BOTH the "
"namespaced key and the flat rollback mirror - which is what an operator "
"reads after the restart the latch is designed to survive",
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, ""))
and f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
f"{_mid1s['grid']:+.0f} W"
in str(_c1s.store.data.get("sign_reason", "")))
check("...the latch is under the same namespaced key with the same flat "
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
"A one-inverter install's state file is byte-identical to what it was",
_c1s.store.data.get(TRIPPED_KEY) is True
and _c1s.store.data.get("sign_tripped") is True
and _c1s.store.data.get("site_sign_tripped") is False
and _c1s.store.data.get("site_sign_reason") == "")
print(f" site detector evaluated {len(_n1_site_calls)} times over "
f"12 cycles")
check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and not "
"'its window stayed closed' - NEVER CALLED, counted on the object over "
"twelve cycles. That is what says the second detector is ABSENT at a "
"roster of one rather than merely quiet, and arming it would be two "
"latches and two banners for one fault with a false-positive record "
"measured on ONE comparison spent twice",
_n1_site_calls == []
and not _c1s.site_sign_wd.tripped
and _c1s.site_sign_wd.disagree_since is None)
check("...the banner is the one-inverter sentence, naming the meter",
_c1s.status_payload()["banner"] == messages.banner_direction(False)
and "meter" in messages.banner_direction(False))
check("...the status page carries ONE sign row, with no unit tag and no site "
"row - an extra row for an identically-duplicated question is a page "
"advertising two guards where the install has one",
len([ck for ck in _c1s.checks() if ck.get("id") == "direction"]) == 1
and not any(ck.get("id") == "site_direction" for ck in _c1s.checks())
and not any("inverter-" in ck["text"] for ck in _c1s.checks()
if ck.get("id") == "direction"))
check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
print()
print("8. SAFETY-32 ROUND 4's ENUMERATION: the two `any(...)` reductions over")
print(" the roster whose only evidence is a latched NON-FIRST unit")
# ⚠️ THIS SECTION IS NOT SAFETY-32's SUBJECT, AND IT IS HERE BECAUSE OF IT.
# SAFETY-32 was rejected three times for the same shape one step over: a
# reduction over roster length whose fixture could not separate it from a slice
# of the roster. Round 4 therefore ENUMERATED every such reduction in
# `Controller` - min, max, sum, any, all, sorted, [0], [-1] over
# `self.inverters` / `self.packs` - and mutated each to `[:1]` to find out which
# were GRADED. Two survived a green 19-file suite, both `any(i.sign_wd.tripped
# for i in self.inverters)`, both SAFETY-30's, and this block is what closes
# them. No shipped behaviour changes; the reductions were already right.
#
# main.py `cycle()`, the stale-input hold - +4 bytes, 19/19 GREEN
# main.py `publish()`, the status vocabulary - +4 bytes, 19/19 GREEN
#
# ⚠️ WHY THE SUITE COULD NOT SEE THEM, and it is the same sentence every time:
# the only fixture that reached either was a roster of ONE, where
# `self.inverters[:1]` IS `self.inverters`. Part 3 above builds a two-inverter
# roster in which ONLY inverter B is latched - the exact data both reductions
# needed - and neither of them was read on it. A fixture of N units cannot
# separate reductions that agree on N units, one card over.
#
# ⚠️ AND BOTH ORDERS ARE RUN, for 12g's reason: a single "only B is latched"
# fixture kills `[:1]` and leaves `[-1:]` - grade the roster by its LAST unit -
# answering identically. The pair is what leaves no single-position slice.
async def _latched_then_input_gap(reversed_idx):
"""Latch ONE unit's sign watchdog, then take a SIBLING's reading away.
⚠️ A REAL LATCH FROM A REAL EPISODE, not `sign_wd.tripped = True`: the same
`_SignHass` plant and the same `_drive()` part 3 uses, so the detector
reaches its verdict through the shipped path. Then one pack's SoC goes
missing for LESS than `stale_input_s`, which is the state the hold exists
for - a single missed poll must not become a fault.
THE FAULT THIS GRADES: a LATCHED unit must take the whole roster past that
hold, because the unit's `failsafe` SET is the thing parking it. Return
early and the unit's last share stays standing for the heartbeat to
re-emit - main.py's own comment: "the trip would stop acting the moment a
poll was missed". A reduction that looked at one roster POSITION would do
exactly that whenever the latched unit was not the position it looked at.
"""
hass = _SignHass(load_w=2000.0, reversed_units={reversed_idx})
c = _sign_site(hass)
await _drive(c)
sibling = c.inverters[1 - reversed_idx]
sibling.soc = None
c.inputs_bad_since = datetime.now(timezone.utc)
await c.cycle()
return c
_gap_b = asyncio.run(_latched_then_input_gap(1))
_gap_a = asyncio.run(_latched_then_input_gap(0))
for _tag, _c, _i in (("B latched", _gap_b, 1), ("A latched", _gap_a, 0)):
_pl = [p for p in (_c.allocation.plans if _c.allocation else ())
if p.unit == _c.inverters[_i].name]
print(f" {_tag}, sibling's SoC missing 0 s: inputs_ok="
f"{_c.inputs_ok}, reason={_c.reason!r}, latched unit target="
f"{_c.inverters[_i].target}, why={_pl[0].why if _pl else '(no plan)'}")
check("PRECONDITION (habit 1): on BOTH runs the site really is in the state "
"the hold is for - one pack's reading is missing, `inputs_ok` is False, "
"the gap is younger than `stale_input_s`, and the SITE latch is NOT "
"tripped, so the only thing that can carry the roster past the hold is "
"the per-unit `any()`. And exactly ONE unit is latched on each run, at "
"the OTHER position",
_gap_b.inputs_ok is False and _gap_a.inputs_ok is False
and _gap_b.site_sign_wd.tripped is False
and _gap_a.site_sign_wd.tripped is False
and [i.sign_wd.tripped for i in _gap_b.inverters] == [False, True]
and [i.sign_wd.tripped for i in _gap_a.inverters] == [True, False])
check("⚠️ A LATCHED UNIT TAKES THE ROSTER PAST THE STALE-INPUT HOLD FROM EITHER "
"POSITION. Neither cycle parks on `inputs missing`; both go on to file "
"claims, and the latched unit is commanded 0 W by its OWN `failsafe` "
"claim, named in the allocation record. Grade this with "
"`self.inverters[:1]` and the B-latched run returns early - the +4-byte "
"mutation that was GREEN across all 19 files - and with "
"`self.inverters[-1:]` the A-latched run does. The remedy stops acting "
"the moment a poll is missed, which is main.py's own sentence for it",
not (_gap_b.reason or "").startswith("inputs missing")
and not (_gap_a.reason or "").startswith("inputs missing")
and _gap_b.inverters[1].target == 0.0
and _gap_a.inverters[0].target == 0.0
and all(any(p.unit == c.inverters[i].name and "failsafe" in p.why
for p in (c.allocation.plans if c.allocation else ()))
for c, i in ((_gap_b, 1), (_gap_a, 0))))
# ...and the counterfactual, which is what makes the two lines above a
# DIFFERENCE rather than a description: with NOTHING latched, the very same
# input gap DOES park the site on the hold. Without this the checks above would
# be satisfied by a `cycle()` that never had a hold at all.
async def _gap_no_latch():
c = _sign_site(_SignHass(load_w=2000.0))
await _drive(c, n=2)
c.inverters[0].soc = None
c.inputs_bad_since = datetime.now(timezone.utc)
await c.cycle()
return c
_gap_none = asyncio.run(_gap_no_latch())
print(f" nothing latched, same gap: reason={_gap_none.reason!r}")
check("...and the counterfactual (habit 7): with NO unit latched the SAME gap "
"DOES hold the site - `inputs missing 0s` - so the two runs above show "
"the latch carrying the roster past a hold that is really there, rather "
"than a hold that never fires",
(_gap_none.reason or "").startswith("inputs missing")
and not any(i.sign_wd.tripped for i in _gap_none.inverters))
# --- and the SECOND survivor: the status vocabulary publish() hands the broker
# ⚠️ THE EXISTING CHECK IS AT A ROSTER OF ONE, which is why this one exists.
# "MQTT's controller-status sensor reports 'failsafe'" above drives a
# single-inverter Controller, where `self.inverters[:1]` is the whole roster.
# A site at 0 W reporting `running` is the failure main.py's comment names, and
# at two inverters it is reachable whenever the latched unit is not the one a
# narrowed reduction happens to read.
_pub = {}
def _published(ctl):
_pub.clear()
ctl.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
"close": lambda _s: None})()
ctl.publish()
return dict(_pub)
_pb, _pa = _published(_gap_b), _published(_gap_a)
_pn = _published(_gap_none)
print(f" published status: B latched -> {_pb.get('status')!r}, "
f"A latched -> {_pa.get('status')!r}, none latched -> "
f"{_pn.get('status')!r}")
check("⚠️ AND `publish()` SAYS `failsafe` WHICHEVER UNIT IS LATCHED. The status "
"sensor is the vocabulary every dashboard already reads, and a site with "
"one inverter parked by its own failsafe claim reporting `running` is "
"the exact failure main.py's comment names. `self.inverters[:1]` "
"publishes `running` on the B-latched site - +4 bytes, GREEN across all "
"19 files - and `self.inverters[-1:]` does it on the A-latched one",
_pb.get("status") == "failsafe" and _pa.get("status") == "failsafe")
check("...and the counterfactual: the same two-inverter site with NOTHING "
"latched publishes `running`, so the two above are a difference the "
"reduction produced and not a constant",
_pn.get("status") == "running" and _gap_none.auto is True)
# --- and the THIRD one the same enumeration found: the OPERATOR'S BANNER -----
# ⚠️ `status_payload()`'s failsafe branch is a fourth `any(... for i in
# self.inverters)`, and `self.inverters[:1]` there was +4 bytes and GREEN across
# all 19 files too - for the third time, because the only fixture that reached
# it was a roster of ONE. The banner is the ONE sentence at the top of the page;
# a site holding an inverter at 0 W under a "Stopped" or "NOT READY" headline
# sends the operator to the wrong question.
_bn_b = _gap_b.status_payload()
_bn_a = _gap_a.status_payload()
_bn_n = _gap_none.status_payload()
print(f" banner: B latched -> {_bn_b['banner']!r}")
print(f" A latched -> {_bn_a['banner']!r}")
print(f" none -> {_bn_n['banner']!r}")
check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED, at "
"level `bad`, with the N>=2 sentence that names the unit's OWN measured "
"power rather than the meter. `self.inverters[:1]` drops the headline on "
"the B-latched site and `self.inverters[-1:]` on the A-latched one, and "
"the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"]
and _bn_b["banner"] == messages.banner_direction(True)
and "own power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test",
_bn_n["banner"] != messages.banner_direction(True)
and _bn_n["banner"] != _bn_b["banner"])
print()
print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are")
print(" asked about - pinned by shares that CANNOT answer for it")
# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD.
# `main.py`'s `site_standing = float(sum(standing.values()))` is the first
# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to
# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. THREE mutations of it
# were demonstrated live against a full green suite, and ALL THREE ARE COVERED
# HERE:
#
# sum(...) / max(1, len(standing)) the MEAN
# max(standing.values(), key=abs) the LARGEST SHARE
# list(standing.values())[0] the FIRST SHARE
#
# ⚠️ ROUND 1 OF THIS CARD SAID THE THIRD ONE WAS ALREADY CLOSED BY SAFETY-32's
# PACKAGE-WIDE STRUCTURAL PIN AND TOLD FUTURE AUTHORS "DO NOT RE-COVER THAT ONE
# HERE". THAT WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART. It was
# measured ONCE; that single run happened to hit test_peakguard.py's ~20%
# CI-06 flake, the red was recorded as a mutation KILL, and the claim was then
# written into four places. RE-MEASURED IN ROUND 2, on release/1.0 @ 86eaef2
# carrying ONLY the `[0]` edit (+4 bytes, re-read from disk): the full suite is
# 21/21 GREEN, and test_peakguard.py run FIVE times in isolation on that same
# mutated tree is PASS PASS PASS PASS PASS. The mutation SURVIVES. (The
# reviewer's own independent re-measurement of the same tree caught the flake
# once - PASS PASS PASS FAIL PASS - which is the same verdict: a survivor, plus
# a demonstration of the flake that produced the original error.) A SINGLE RED
# FROM test_peakguard.py PROVES NOTHING - re-run it at least five times and NAME
# the failing check before believing it. That rule is CI-06's, and this card is
# the third measurement it has corrupted.
#
# THE MECHANISM, confirmed by reading 12j's own enumeration rather than by
# argument. SAFETY-32's part 12j taints names BOUND to roster-carrying values
# and lists ACCUMULATED in a loop over one. `standing` is neither: it is a dict
# created empty at main.py:1554 and filled by SUBSCRIPT assignment
# (`standing[inv.name] = stood`) inside the roster loop, and a subscript store
# is not a binding 12j follows. So `standing` never enters its taint set at all,
# and `sum(standing.values())` is not a roster site. Run 12j on a tree carrying
# the `[0]` mutation and it enumerates main.py:1553, 1555 and 1690 - the three
# `self.inverters` sites around this code - and NOT 1599. A structural pin
# cannot see this line. Only the behavioural fixture below can.
#
# Identical shape, different meaning - which is precisely the blind spot
# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean
# are edits invisible to any shape pin, permanently"). No shape pin will ever
# close them. Only a behavioural fixture can, and this is it.
#
# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING
# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is
# the only N>=2 site fixture in the suite and it runs at
# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of
# them individually far OVER the gate. On values like those the sum, the mean
# and the largest share all clear it, so all three reductions agree, and a
# check whose two candidate answers happen to be EQUAL cannot observe a change
# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there
# `sum == mean == max == the one share` identically, so every mutation is
# EQUIVALENT there - see 9c, where that identity is asserted rather than
# assumed.
#
# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE
# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W
# stand at their own rails, so `standing` is {-220, -280} in roster order:
#
# sum -500 W |500| > 300 OVER the gate -> TRIPS
# mean -250 W |250| < 300 under it -> silent
# max(..., key=abs) -280 W |280| < 300 under it -> silent
# list(...)[0] -220 W |220| < 300 under it -> silent
#
# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears
# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal
# shares would collapse mean, max and first onto one number and the fixture
# could not tell them apart, and putting the SMALLER unit first is what keeps
# `[0]` distinct from `max(key=abs)`.
#
# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W
# are chosen for the arithmetic in that table and for nothing else. In
# particular they are NOT inverter B, which does not physically exist; B's
# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in
# this section is a measurement of anything.
#
# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is
# left at its default so this fixture is graded against the magnitude an
# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s
# because the hold is a wall clock and the campaign behind 325 s is measured on
# the rig, not here. The gate is the coordinate this section is about.
from app import peakguard as _pg38 # noqa: E402
from app.freeze import rail_w as _rail_w38 # noqa: E402
from app.p1 import P1Sample as _P1Sample38 # noqa: E402
import time as _time38 # noqa: E402
_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above.
_S38_LOAD_W = 2000.0
_S38_FROZEN_METER_W = 2500.0
def _s38_roster(*ratings):
"""A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs.
⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second".
`Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` -
so a unit appended afterwards leaves the site loop bounded at ONE unit's
rating. That is harmless where part 3 uses it and fatal here: the whole
fixture is a site command standing at the ROSTER's rail.
"""
return [{"name": name, "setpoint_entity": f"number.sp_{tag}",
"soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}",
"max_w": w, "bms_soc_floor": 10}
for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"),
("a", "b", "c"), ratings)]
class _S38Plant:
"""Units that obey INSTANTLY and a meter that is `load - sum(measured)`.
The same modelling choice part 3 makes and for the same reason: an instant
plant removes the one mechanism that could make an obedient unit look
disobedient, so what remains to be graded is the ATTRIBUTION.
"""
def __init__(self, load_w=_S38_LOAD_W):
self.load_w = float(load_w)
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 []
return [float((i.last_write if i.last_write is not None else i.target) or 0.0)
for i in self.ctrl.inverters]
async def number(self, entity, invert=False):
m = self.measured()
if entity == "sensor.grid":
return self.load_w - sum(m)
for i, tag in enumerate(("a", "b", "c")):
if entity == f"sensor.soc_{tag}":
return 50.0
if entity == f"sensor.batt_{tag}":
return m[i] if i < len(m) else None
return None
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
return True
def _s38_ctrl(hass, ratings, store=None, **over):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": _s38_roster(*ratings),
"gain": -0.6,
# The per-unit pair, distinct from the site pair in BOTH
# coordinates, for part 3's reason: an equality below must be able
# to fail on the magnitude alone or on the hold alone.
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
# ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W
# default is the gate this whole section is graded against.
"site_sign_disagree_s": 2,
# SAFETY-31 is given a long window so the SITE is not stopped by
# circulation while this section grades the sign attribution. It is
# isolation, not a claim that SAFETY-31 is silent here.
"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
def _s38_stood(c):
return [inv.last_write if inv.last_write is not None else inv.target
for inv in c.inverters]
print()
print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose")
print(" SUM clears it - the case the site detector exists for")
async def _s38_site():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
seen = []
for _ in range(20):
await c.read_inputs()
await c.cycle()
seen.append(dict(grid=c.grid, stood=_s38_stood(c),
meas=[i.batt for i in c.inverters],
site=c.site_sign_wd.tripped,
units=[i.sign_wd.tripped for i in c.inverters],
following=c.following))
await asyncio.sleep(0.2)
return c, seen
_c38, _s38seen = asyncio.run(_s38_site())
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
_pre38 = [x for x in _s38seen if not x["site"]]
_mid38 = nth(_pre38, -1, "no cycle before the trip")
_GATE38 = _c38.site_sign_wd.cfg.min_w
_share38 = list(_mid38["stood"])
_red38 = {"sum": sum(_share38),
"mean": sum(_share38) / max(1, len(_share38)),
"max(key=abs)": max(_share38, key=abs, default=nth([], 0)),
"[0]": nth(_share38, 0)}
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _share38]} measured "
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold "
f"{_c38.site_sign_wd.cfg.hold_s:.0f} s")
for _k, _v in _red38.items():
print(f" {_k:>13} = {_v:+7.1f} W "
f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate")
check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is "
"graded against the magnitude an install runs and not against a number "
"chosen to make it pass",
_GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0)
check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share "
"is BELOW the site gate and their SUM is OVER it. `_sign_site` above "
"cannot make this statement - its shares are each individually over the "
"gate, so sum, mean and largest-share all agree there and its values "
"cannot observe a change that swaps them",
len(_share38) >= 2
and all(abs(s) < _GATE38 for s in _share38)
and abs(sum(_share38)) > _GATE38)
check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, "
"of which exactly ONE clears the gate. That is what makes this fixture "
"able to tell `sum` from the mean, from the largest share and from the "
"first share - and it is the property the existing site fixture lacks",
len({round(v, 6) for v in _red38.values()}) == 4
and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"])
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is charging into an "
"importing house. Without this the trip below could be a per-unit fault "
"in disguise rather than the site fault this grades",
present(_mid38)
and all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
and sum(_mid38["stood"]) * _mid38["grid"] < 0
and _mid38["following"] is True)
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
"Under ALL THREE semantic mutations - `sum -> mean`, "
"`sum -> max(key=abs)` AND `sum -> list(...)[0]` - NOTHING trips, ever: "
"the reviewer's tree was still commanding -500 W of charge into a "
"+2500 W import at t = 595 s with following=True. This is the check all "
"three go red on. `[0]` is here BECAUSE IT IS NOT COVERED ELSEWHERE - "
"see the section header for the mis-measurement that said it was",
_c38.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38.inverters))
print(f" {_c38.site_sign_wd.reason!r}")
check("⚠️ ...and the sentence an operator reads carries THE SUM IN THE "
"COMMANDED SLOT AND THE METER IN THE METER SLOT - the ORDERED PAIR, not "
"two numbers loose in a sentence. ROUND 1 ASSERTED `f'{sum(...):.0f} W' "
"in reason`, i.e. that '-500 W' appears SOMEWHERE, AND THAT WAS A HOLE "
"ONE LINE WIDE. Swapping the detector's first two arguments - "
"`evaluate(site_standing, self.grid, ...)` -> "
"`evaluate(self.grid, site_standing, ...)`, a ZERO-BYTE edit at "
"main.py:1620 - leaves BOTH numbers in the string and moves each into "
"the other's slot, so the substring test still passed and the whole "
"suite stayed green. The swapped banner reads 'commanded +2500 W "
"against a meter reading -500 W': the site is standing at -500 W of "
"charge INTO a +2500 W import and the latching, site-stopping banner "
"tells the operator the exact opposite, on the one line somebody woken "
"by this alarm reads first. watchdog.py:658-665 calls naming the wrong "
"instrument there 'a false statement about which instrument produced "
"the evidence'. A check whose data cannot reach the failure it names is "
"MUTATION.md's own defect; this is the line that closes it",
f"commanded {sum(_share38):+.0f} W against a meter reading "
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason)
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
"rather than by some other guard happening to stop it",
_c38.target == 0.0 and "sign_site" in _c38.reason
and all(i.target == 0.0 for i in _c38.inverters)
and _c38.store.data.get("site_sign_tripped") is True)
# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the
# SUM crossing the gate rather than by two inverters being present. Same plant,
# same roster shape, same everything - except the units are rated so that the
# sum lands UNDER the gate. Nothing may trip.
_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate
async def _s38_under():
hass = _S38Plant()
c = _s38_ctrl(hass, _S38_SMALL)
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38u = asyncio.run(_s38_under())
print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} "
f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W")
check("...and the counterfactual: the SAME inverted-gain site whose shares sum "
"to 250 W - under the gate - does NOT trip, over the same 20 cycles. So "
"the trip above is the sum crossing 300 W and not the mere presence of a "
"second inverter",
abs(sum(_s38_stood(_c38u))) < _GATE38
and not _c38u.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38u.inverters))
# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS
# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR.
# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of
# the sum of what actually stands on the wire - is -11 bytes, and it survived
# every check above AND the whole 20-file suite, because in every fixture so
# far the two are the same number. THEY ARE NOT THE SAME NUMBER when a setpoint
# device's own reported range is narrower than its `max_w`: the `device`
# P_SAFETY claim is filed PER ACTUATOR (main.py, `actuator_of("device", inv)`),
# so it bounds each unit's SHARE and never the site target, and
# `clamp_to_rating()` clips again on the way out. Here the site asks for -500 W
# and -250 W stands.
#
# WHICH ONE THE GUARDS MEAN IS ALREADY WRITTEN DOWN, IN BOTH OF THEM: SAFETY-02
# grades "the command standing on the wire", and `looks_frozen()`'s docstring
# says `commanded_w` is "the EMITTED setpoint - what the arbiter resolved and
# `write_setpoint()` put on the wire - not the loop's raw wish". SAFETY-20 is
# the whole card about the difference. This check is those sentences made to
# bite: on the site target the detector fires on a site that is standing at
# -250 W against a +2250 W meter - under its own 300 W gate - which is a
# LATCHING, site-stopping false trip on an install whose inverters simply
# report a narrower range than the operator typed.
_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings
async def _s38_devclamped():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
for inv, d in zip(c.inverters, _S38_DEV):
inv.dev_min, inv.dev_max = -d, d
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38d = asyncio.run(_s38_devclamped())
_stood38d = _s38_stood(_c38d)
print(f" device-clamped: site target {_c38d.target:+.0f} W but only "
f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, "
f"meter {_c38d.grid:+.0f} W")
check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT "
"numbers here, on opposite sides of the gate - -500 W asked, -250 W "
"standing. Without that separation this check and the one above compare "
"two values that happen to be equal, which is the defect this card is "
"the sixth instance of",
abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38)
check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON "
"THE WIRE and not what the arbiter asked for. `float(self.target)` is "
"-11 bytes, survives the other 20 files, and turns this ordinary "
"narrow-rating install into a latching site-wide false trip",
not _c38d.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38d.inverters)
and _c38d.target != 0.0)
print()
print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`")
print(" inside `looks_frozen()`, and both mutations blind it too")
# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY.
# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide(
# ..., frozen=...)` call, where it is the EMITTED command graded against the
# site rail. Both surviving mutations shrink it there too, and nothing went red.
#
# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different
# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W.
#
# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W
# mean 250 W < 475 silent
# max(..., key=abs) 280 W < 475 silent
# list(...)[0] 220 W < 475 silent
#
# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here the gain is the SHIPPED
# POSITIVE one and the frozen meter reads IMPORT, so the site discharges +500 W
# against a +2500 W import - the two agree in sign, SAFETY-30 has nothing to
# say, and this section grades SAFETY-18 alone. Reusing 9a's inverted gain
# would have the site sign latch park everything at 0 W and the freeze
# conjunction could then never be true: a check measuring the other guard's
# remedy.
#
# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped
# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not
# import while the billed quarter cannot be measured". Under both mutations it
# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on
# charging through a quarter whose billed average is unobservable. That is the
# capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
class _S38Src:
"""The transport's own `unchanged_s`, which only homewizard_local has."""
def __init__(self, unchanged_s):
self.unchanged_s = float(unchanged_s)
def _s38_feed(c, w, ts=None, mono=None):
now = ts if ts is not None else datetime.now(timezone.utc)
c.p1.submit(_P1Sample38(
ingest_ts=now,
ingest_mono=mono if mono is not None else _time38.monotonic(),
telegram_ts=now, source="homewizard_local",
import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w,
per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),)))
def _s38_seed_quarter(c, w):
"""Backfill the OPEN quarter with the same stuck value, at 10 s spacing.
⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and
`blindness()` answers BLIND_COVERAGE - which is still blind, still floor
0 W, and would therefore MASK the mutations: the mutant would reach the
same action by a different route and a check on the action alone could not
see the difference it names. Seeded, the mutant is not blind at ALL, which
is the honest measurement of what the mutation costs. A meter that has been
stuck at the same reading all quarter is also what SAFETY-18 is about.
"""
wall = _time38.time()
q0 = (int(wall) // 900) * 900
mono0 = _time38.monotonic() - (wall - q0)
t = float(q0)
while t < wall:
_s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0))
t += 10.0
def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
# ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the
# shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no
# BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site
# climbs 125 W a cycle and crosses 475 W part way through the run, so the
# SAME run carries both answers.
c = _s38_ctrl(hass, ratings, meter_source="homewizard_local",
p1_host="meter.local", gain=gain)
c.p1_source = _S38Src(unchanged_s)
return c
# ⚠️ `n` IS 20 AND WAS 8 (SAFETY-39). The comment on `_s38_frozen_ctrl` says
# gain 0.05 is there "to buy a ramp" so that the SAME run carries both a
# below-the-rail and an at-the-rail answer. At the shipped rail-following
# `slew_w` the ramp is now the SLEW - 55 W a cycle against this 500 W site,
# where gain 0.05 alone gave 125 - so eight cycles no longer reach the rail and
# the run carried only the counterfactual. The fixture needs the cycles, not a
# different tuning: the point is a run that crosses the rail part way through.
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=20):
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, ratings)
_s38_seed_quarter(c, meter_w)
seen = []
for _ in range(n):
_s38_feed(c, meter_w)
await c.read_inputs()
await c.cycle()
pg = c.peak_guard
seen.append(dict(stood=_s38_stood(c),
blind=None if pg is None else pg.blind,
floor=None if pg is None else pg.floor_w))
await asyncio.sleep(0.05)
return c, seen
_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W)))
_RAIL38 = _rail_w38(_c38f.site_max_w,
dev_min=_c38f.site_dev_bound("dev_min"),
dev_max=_c38f.site_dev_bound("dev_max"))
_last38 = _s38f[-1]
_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38]
print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; "
f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for "
f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold "
f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)")
for _x in _s38f:
print(" stood %-16s sum %+7.1f W floor=%s blind=%r"
% ([round(v) for v in _x["stood"]], sum(_x["stood"]),
_x["floor"], _x["blind"]))
check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail "
"and their SUM is at or over it - the same discrimination as 9a, at "
"SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the "
"largest share are 220, 250 and 280 W against a 475 W rail",
len(_last38["stood"]) >= 2
and all(abs(s) < _RAIL38 for s in _last38["stood"])
and abs(sum(_last38["stood"])) >= _RAIL38
and len({round(v, 6) for v in
(sum(_last38["stood"]),
sum(_last38["stood"]) / len(_last38["stood"]),
max(_last38["stood"], key=abs),
_last38["stood"][0])}) == 4)
check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids "
"charging - floor 0 W - because the SITE's total command is at the rail "
"while the meter has stopped moving. Under `sum -> mean` and "
"`sum -> max(key=abs)` it is not blind at all and files no floor, so the "
"battery may keep importing through an unmeasurable billed quarter. This "
"is the second consumer, and it is covered rather than scoped out",
_last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0)
check("...and the counterfactual, from the SAME run: on the earlier cycles the "
"site command had not yet reached the rail, and the guard did NOT report "
"SAFETY-18 - with the meter equally frozen the whole time. So the "
"verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, "
"not by `unchanged_s` alone",
len(_below38) >= 2
and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38))
print()
print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted")
# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a
# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME
# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at
# N=1 can ever separate them. That is a fact about the arithmetic, and leaving
# it implicit is how "the shipped install is covered" gets believed. It is
# asserted here, on a real one-inverter controller's own standing command, so
# the claim rests on the roster rather than on the reader.
_S38_ONE = (500.0,)
async def _s38_one():
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, _S38_ONE)
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
for _ in range(20): # SAFETY-39: the slew is the ramp now - see _s38_frozen
_s38_feed(c, _S38_FROZEN_METER_W)
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.05)
return c
_c38one = asyncio.run(_s38_one())
_one38 = _s38_stood(_c38one)
_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)),
max(_one38, key=abs), _one38[0])
print(f" roster of {len(_c38one.inverters)}: stood "
f"{[round(v) for v in _one38]} -> sum/mean/max/first = "
f"{tuple(round(v, 1) for v in _onered38)}")
check("at a roster of ONE the four reductions are ONE NUMBER - so all three "
"mutations of `site_standing` are EQUIVALENT on a shipped install and no "
"N=1 fixture could ever tell them apart. That is why this card needed a "
"roster of two, stated as an assertion rather than left to the reader",
len(_c38one.inverters) == 1
and len({round(v, 6) for v in _onered38}) == 1)
check("...and the one-inverter site still reaches SAFETY-18's verdict on that "
"same number, unchanged: the command is at the rail, the meter has "
"stopped moving, the guard is blind and forbids charging",
_c38one.peak_guard is not None
and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN
and _c38one.peak_guard.floor_w == 0.0
and abs(sum(_one38)) >= _rail_w38(
_c38one.site_max_w,
dev_min=_c38one.site_dev_bound("dev_min"),
dev_max=_c38one.site_dev_bound("dev_max")))
check("...and the SITE sign detector is still absent at a roster of one - part "
"7 counts the calls; this only records that a fixture built the other "
"way round, from an `inverters:` roster, reaches the same state",
not _c38one.site_sign_wd.tripped
and _c38one.site_sign_wd.disagree_since is None)
# =========================================================================== #
# 10. SAFETY-10 - THE SIGN WATCHDOG IN SHADOW MODE
# =========================================================================== #
# THE QUESTION THE CARD ASKS OF EVERY FAILSAFE: does it observe, does it latch,
# or is it inert in shadow mode? For this one the answer had to be INERT, and
# the reason is the worst outcome in the feature:
#
# SAFETY-02 grades the command STANDING ON THE WIRE against the meter. Shadow
# mode holds the wire at 0 W, so the detector cannot arm. Fed the SHADOW
# DECISION instead it would compare a number against a meter that never saw
# it - guaranteed disagreement for as long as the decision asked for power -
# and TRIP. That latch survives into live operation AND across a restart
# (part 3 of this file is that property), so a shadow run would hand the
# operator a battery that will not move, from a run in which nothing moved.
# Shadow mode would manufacture the failsafe it exists to help debug.
#
# ⚠️ GRADED ON THE SAME STIMULUS THAT TRIPS IT LIVE, NOT ON A QUIET ONE.
# MUTATION.md: a per-thing check that has never seen the things DISAGREE is a
# total. `_s10_sign()` is `_e2e()` above with one option changed - the same
# inverted loop, the same steady 2000 W import, the same 1 s window.
print("\n10. SAFETY-10 - the sign watchdog in shadow mode\n")
async def _s10_sign(shadow_mode):
hass = _Hass()
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,
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True,
# ⚠️ AN EXPLICIT SLEW, AND THE REASON IS THE LIMITATION GRADED
# BELOW. At `slew_w: 0` the rail is `max_w / 9` = 222 W, and in
# shadow mode the loop starts from the 0 W standing on the wire
# EVERY cycle - so its decision never leaves 222 W and never
# reaches the 300 W gate at all. The detector would then be
# "inert" for a reason that has nothing to do with this card.
"slew_w": 1000,
"shadow_mode": shadow_mode}
c = Controller(opts, hass, _S(), _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
wanted = []
for _ in range(12):
await c.read_inputs()
await c.cycle()
wanted.append(c.shadow_would_w if c.shadow else c.target)
await asyncio.sleep(0.25)
return c, hass, wanted
_s10_live, _s10_lh, _s10_lw = asyncio.run(_s10_sign("off"))
_s10_shad, _s10_sh, _s10_sw = asyncio.run(_s10_sign("on"))
print(f" live : tripped={_s10_live.sign_wd.tripped} "
f"wanted={[round(w) for w in _s10_lw[:4]]}")
print(f" shadow: tripped={_s10_shad.sign_wd.tripped} "
f"wanted={[round(w) for w in _s10_sw[:4]]}")
check("PRECONDITION: the same stimulus TRIPS the watchdog with shadow mode "
"off, so what follows is a claim about shadow mode and not about a "
"stimulus that could never have tripped anything",
_s10_live.sign_wd.tripped)
check("PRECONDITION: and in shadow mode the decision path still asked for "
"charge into that import - well past the 300 W gate - so the detector "
"had a wrong-way number available to it and did not use it",
any(w is not None and w < -300.0 for w in _s10_sw))
check("SAFETY-10: the sign watchdog is INERT in shadow mode. It grades what "
"this controller has standing on the wire, and that is 0 W - so a latch "
"that would survive into live operation and across a restart is never "
"armed by a run in which nothing was commanded",
not _s10_shad.sign_wd.tripped
and _s10_shad.sign_wd.disagree_since is None)
check("...and it is inert BY CONSTRUCTION, not by a special case: the value it "
"was handed is `Inverter.standing_w()`, which is the 0 W shadow mode "
"actually wrote and not the decision it recorded",
_s10_shad.inverters[0].standing_w() == 0.0
and set(_s10_sh.writes) == {0.0})
# ⚠️ AND THE LIMITATION IS RECORDED HERE RATHER THAN LEFT FOR SOMEBODY TO
# DISCOVER. `_shadow_settle()` re-seeds the loop's `prev_w` to what is standing
# on the wire, so in shadow mode the loop leg answers "what would this
# controller command NOW, from a battery at 0 W" - ONE STEP - and not "what
# trajectory would it have followed". The trajectory is a closed-loop property
# and it did not happen; reporting it would be a simulation of a plant that
# does not exist.
#
# ⚠️ AMENDED BY SAFETY-47. The three lines that stood here said "the strategies
# are unaffected ... which is why the three defects this card was written for
# are all visible from a shadow run and this one is not." That was true until
# SAFETY-45 merged, one commit earlier: the buy leg is now sized from the
# BILLED-QUARTER METER, which is a function of what the battery did. So this is
# NOT the loop leg's own limitation - it is the GENERAL case, and the loop leg
# is merely where it had already been noticed. A shadow run cannot surface any
# closed-loop defect, and of the three this card was written for exactly one -
# TARIFF-14's wrong quarter-hour - is visible from one. `test_arbitrage.py`
# part 3d grades that; this check keeps grading the loop leg, which is what it
# has always been for.
check("RECORDED LIMITATION: the loop leg's shadow decision is ONE slew step "
"from the standing command and stays there, while the live run RAMPS. A "
"shadow run answers 'what would it do now', not 'what trajectory would "
f"it have followed' (shadow {[round(w) for w in _s10_sw[:4]]} vs live "
f"{[round(w) for w in _s10_lw[:4]]})",
len({round(w) for w in _s10_sw if w is not None}) == 1
and len({round(w) for w in _s10_lw[:4]}) > 1)
# ⚠️ AND "INERT" HAD TO BE SHOWN TO MEAN STARVED, NOT DISABLED. A detector
# that had been switched off would satisfy every check above and would still
# be off the moment shadow mode ends. So the SAME object, off the SAME
# controller, is handed the disagreement the run never let it see.
_s10_n0 = datetime.now(timezone.utc)
_s10_wd = _s10_shad.inverters[0].sign_wd
check("PRECONDITION: it is not tripped going in, so what follows is this "
"stimulus and not a latch left over from the run",
not _s10_wd.tripped)
_s10_wd.evaluate(-1500.0, 2000.0, True, _s10_n0)
_s10_fired = _s10_wd.evaluate(-1500.0, 2000.0, True,
_s10_n0 + timedelta(seconds=5))
check("...and the failsafe is STARVED, not DISABLED: handed -1500 W standing "
"against a +2000 W import - the evidence shadow mode never lets it see - "
"the very same detector object trips. It is armed for the moment shadow "
"mode ends",
bool(_s10_fired) and _s10_wd.tripped)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")