The Supervisor questions for the Details link are asked together with a 3 s budget instead of one after the other at 10 s each, so a hung Supervisor cannot hold control start for 20 s. In shadow mode the circulating and site-direction latches are pinned to keep their own red row that says how they clear. Plainer inverter and battery wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3053 lines
159 KiB
Python
3053 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 "never fire" 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 "never fire" 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("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 command" 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")
|