Files
goodwe-addon/goodwe_controller/test_freshness.py
T
adminandClaude Opus 5 43c144b8d9
checks / unit (pull_request) Successful in 7m58s
SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.

REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:

  10:51:24.7  0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
              the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
  10:51:29.7  the command reaches +1060 W - 800 W of travel
  10:52:00    the conjunction arms. Inverter.batt becomes None
  10:52:21.7  the site commands 0 W, on the wire
  ...         0x0518 does not change value for 850.0 s. The setpoint does not
              change value for 790.0 s. The house exports 2.4 kW throughout
  ~11:04      a human restarts the add-on. That is what ends it

AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.

THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.

THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:

  the verdict is withdrawn when the obligation that produced it has been gone
  for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.

WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.

Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.

main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.

Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-21 12:33:56 +02:00

1638 lines
84 KiB
Python

"""Runnable check for SAFETY-37's freshness policy. `python3 test_freshness.py`
THE SUBJECT OF THIS FILE IS THE FIXTURE, NOT THE CODE. A pack legitimately
resting at 40 % and a dead sensor reading 40 % ARE THE SAME NUMBER. A check that
cannot separate "fresh and unchanged" from "stale" cannot see the failure it
names, and this repository has recorded that defect - "a check whose DATA cannot
reach the failure it names" - eleven times. So part 1 builds the separation and
ASSERTS THE VALUE STREAMS ARE BIT-IDENTICAL across the pair; everything after it
is downstream of that pair being real.
⚠️ AND THE CARD'S WORDING HAD TO BE SHARPENED TO BUILD IT, WHICH IS SAID HERE
RATHER THAN QUIETLY WORKED AROUND. The card says "only arrival separates them".
For a signal this add-on polls out of Home Assistant's state cache, ARRIVAL
CANNOT SEPARATE THEM: `read_inputs()` runs about once a second and Home
Assistant answers 200 with a number whether or not the publisher behind the
entity is alive, so both members of the pair arrive, identically, forever.
Part 1a demonstrates exactly that - an arrival-age gate is graded and FAILS to
tell the pair apart - and it is the reason the shipped verdict is a conjunction
with an OBLIGATION term rather than an age threshold. Every clock in that
verdict is still an arrival clock: nothing reads a Home Assistant timestamp,
which is the half of "arrival-stamped" that TEL-06 is blocked on and that this
module does honour.
1. THE PAIR. Fresh-and-unchanged vs stale, on identical value streams, on
the policy object itself. Plus 1a, the arrival-age gate that cannot.
2. THE WIRING. The same pair driven through the REAL `Controller.read_inputs`
at a roster of TWO, with the two units carrying DIFFERENT power values.
3. THE CONSUMERS REFUSE, and the counterfactual is run beside them: the
per-unit sign check and the circulating-power sensor, with the gate and
with the gate removed, through real `cycle()` calls.
4. THE LATCH, and the ~1 Hz oscillation it exists to prevent.
5. N=1. Nothing changes on a healthy install - asserted on the shipped read
path, not assumed - and the one thing that DOES change is named.
6. THE BOUNDS, graded against the shipped `config.yaml` at both ends.
7. THE POLICY TABLE tells the truth about what is wired.
8. THE MEASUREMENT the bound rests on, replayed from the committed capture.
9. KNOWN SURVIVORS, graded so they are residuals rather than surprises.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12; goodwe/NOTES.md §1.1b).
Every two-unit case below is two copies of inverter A's behaviour with
DIFFERENT numbers passed in, and no number here is a measured parameter for a
second device.
⚠️ 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. Read MUTATION.md
before you report a survivor.
"""
import ast
import asyncio
import dataclasses
import glob
import gzip
import hashlib
import importlib.util
import io
import json
import logging
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from app import freshness as F
from app.circulation import circulating_w
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
from app.inverter import Inverter
from app.main import Controller
from app.store import DEFAULTS
from app.watchdog import SignWatchdog, SignWatchdogConfig, disagrees
fails = []
HERE = Path(__file__).resolve().parent
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
# --------------------------------------------------------------------------- #
# fixtures
# --------------------------------------------------------------------------- #
class MemStore:
def __init__(self, data=None):
self.data = dict(DEFAULTS, **(data or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Hass:
"""A Home Assistant that answers per ENTITY and RECORDS what it served.
⚠️ PER ENTITY, AND THE RECORD IS LOAD-BEARING. A stub that answered one
number whatever it was asked could not tell "B was read from B's sensor"
from "B was handed A's reading" - and the served-value log is what lets
part 1 assert that the fresh run and the stale run saw the SAME numbers.
"""
def __init__(self, numbers=None, limits=None):
self.numbers = dict(numbers or {})
self.limits_by = dict(limits or {})
self.served = [] # (entity, value) in call order
self.writes = []
async def number(self, entity, invert=False):
v = self.numbers.get(entity)
self.served.append((entity, v))
return None if v is None else (-v if invert else v)
async def limits(self, entity):
return self.limits_by.get(entity, (None, None, None))
async def set_number(self, entity, value):
self.writes.append((entity, value))
return True
async def call(self, domain, service, data):
return True
A_RATING = 5000.0
# ⚠️ ILLUSTRATIVE, AND NOT B'S DATASHEET FIGURES. SPIKE-04a established B as a
# GW5048D-ES rated 4600 W with a 10 % floor (goodwe/NOTES.md §1.1b), and this
# fixture deliberately does not use them: 5000/4600 is a 400 W gap, narrower
# than a slew step, and two equal-ish numbers cannot show a check that read the
# wrong unit's. Same reasoning as test_inverter.py's `B_RATING_HYPOTHESIS`.
B_RATING_SIM = 3000.0
A_FLOOR, B_FLOOR_SIM = 10, 15
ROSTER = [
{"name": "inverter-a", "setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": A_RATING, "bms_soc_floor": A_FLOOR},
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": B_RATING_SIM, "bms_soc_floor": B_FLOOR_SIM},
]
# ⚠️ A AND B CARRY DIFFERENT POWER VALUES IN EVERY FIXTURE BELOW, and the card
# names this as the trap it expects: "if both read the same, the test cannot
# distinguish a frozen reading from a live one and will pass against the broken
# code". SAFETY-30's fix round walked into exactly that with two watchdog holds
# both set to 1.
A_FROZEN_W = -1200.0 # inverter A's register, stuck on a charge figure
B_LIVE_W = 900.0 # inverter B's register, moving, and NOT A's number
BASE = dict(meter_source="off", meter_entity="sensor.grid", setpoint_entity="",
soc_entity="", batt_entity="", max_w=A_RATING, gain=0.6, slew_w=0,
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
stale_input_s=15, inverters=ROSTER)
def site(opts=None, numbers=None):
"""A real two-inverter Controller off the shipped `build_roster()`."""
hass = Hass(dict({"sensor.grid": 0.0,
"sensor.soc_a": 50.0, "sensor.batt_a": A_FROZEN_W,
"sensor.soc_b": 55.0, "sensor.batt_b": B_LIVE_W},
**(numbers or {})),
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
"number.sp_b": (-B_RATING_SIM, B_RATING_SIM, 1.0)})
c = Controller(dict(BASE, **(opts or {})), hass, MemStore(), Pub(),
stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
c.inverters[1].dev_min, c.inverters[1].dev_max = -B_RATING_SIM, B_RATING_SIM
return c, hass
# ==============================================================================
print()
print("1. THE PAIR: 'fresh and unchanged' vs 'stale', on IDENTICAL value streams")
# The two runs below feed the policy object the SAME forty readings of the same
# number at the same instants. The ONLY difference is what the controller was
# asking the inverter to do while those readings arrived.
#
# FRESH the site is converged. The command sits where it is, the register
# answers the same watt, and nothing is wrong. This is the case the
# 30-day capture in part 8 measures at up to 263.2 s.
# STALE the site reversed. The command travelled 2500 W and the register did
# not move by one watt in 40 s, which is 2.4x the fastest it could
# possibly be obliged to answer in.
STREAM = [(float(t), A_FROZEN_W) for t in range(0, 40)]
def drive(bound, stream, standing_of):
"""Feed a Freshness one arrival per (t, value). Returns (obj, served, verdicts)."""
f = Freshness(bound)
served, verdicts = [], []
for t, v in stream:
f.submit(v, standing_of(t), t)
served.append(v)
verdicts.append(f.reading())
return f, served, verdicts
_fresh, _fresh_served, _fresh_read = drive(
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W)
# The command travels from -1200 W to +1300 W at t=1 and stays there: a site
# turnaround, which is the ordinary stimulus SAFETY-31's own campaign sweeps.
_stale, _stale_served, _stale_read = drive(
BOUNDS["batt"], STREAM, lambda t: A_FROZEN_W if t < 1 else 1300.0)
print(f" served (both runs): {_fresh_served[:3]} ... x{len(_fresh_served)}")
# ⚠️ `key=str`, AND IT IS NOT COSMETIC. Without it this line raises TypeError
# comparing None with a float the moment a mutation makes the FRESH run stale -
# which is what M2 (dropping the command-travel term) did: the file died on a
# DIAGNOSTIC PRINT before check one, so the harness reported "red" with no named
# check behind it. MUTATION.md's reporting rule is "which suite went red, and on
# WHICH NAMED CHECK"; a print that can crash first destroys exactly that.
print(f" fresh verdicts: unique={sorted(set(_fresh_read), key=str)}")
print(f" stale verdicts: first None at t="
f"{next((i for i, v in enumerate(_stale_read) if v is None), None)}")
check("⚠️ THE FIXTURE'S OWN PRECONDITION: the two runs saw a BIT-IDENTICAL "
"value stream. If this ever stops being true the pair below is comparing "
"two different experiments and proves nothing about freshness",
_fresh_served == _stale_served
and len({v for v in _fresh_served}) == 1)
check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
"with the command standing still - a converged site - and the reading "
"reaches its consumers untouched",
not _fresh.stale and _fresh_read == [A_FROZEN_W] * len(STREAM))
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
"numbers - and the command moved 2500 W underneath them. The reading "
"becomes None to its consumers, which is the whole card",
_stale.stale and _stale_read[-1] is None)
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
"arrivals, so the last fresh verdict is at t=34 and the first None at "
"t=35",
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
and BATT_UNCHANGED_S == 35.0)
check("...and NEVER a held number and never a zero - `reading()` has exactly "
"two shapes, the arrived value or None",
set(type(v) for v in _stale_read) == {float, type(None)}
and 0.0 not in [v for v in _stale_read if v is not None])
check("...and the reason names the three terms an operator has to check, with "
"the numbers that fired it",
"STALE_READING" in _stale.reason and "2500 W" in _stale.reason
and "unchanged for 35 s" in _stale.reason)
# --- 1a. the gate the card asked for first, graded and found unable ----------
# An arrival-age gate is what "arrival-stamped" reads as on first hearing. It is
# built here and run against the SAME pair, because a criterion that cannot be
# satisfied is worth more said out loud than approximated in silence.
_arrival_ages = []
_af = Freshness(BOUNDS["batt"])
for t, v in STREAM:
_af.submit(v, A_FROZEN_W, t)
# Every read produced a number, so on both members of the pair the age
# since the last ARRIVAL is 0.0 at every instant. There is nothing to
# threshold.
_arrival_ages.append(0.0 if _af.last is not None else None)
check("⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART, and that is why the "
"shipped verdict is not one. Home Assistant answered every read with a "
"number in BOTH runs, so the age since arrival is 0.0 s at every instant "
"of both - a threshold on it is a threshold that can never fire, which "
"is TEL-06's defect rebuilt in this repository",
set(_arrival_ages) == {0.0} and _af.arrivals == len(STREAM))
check("...while the arrival record IS what the shipped verdict is measured on: "
"`unchanged_s` is the gap between arrivals that carried different "
"numbers, on this process's own monotonic clock, and no Home Assistant "
"timestamp is read anywhere in the module",
_stale.unchanged_s(39.0) == 39.0
and "last_updated" not in io.open(HERE / "app" / "freshness.py",
encoding="utf-8").read().split(
'"""', 2)[2])
check("...and a read that produced NOTHING is still an immediate None, exactly "
"as it is today - a failed read is not an arrival and is not an age",
Freshness(BOUNDS["batt"]).reading() is None
and (lambda f: (f.submit(5.0, 0.0, 0.0), f.submit(None, 0.0, 1.0),
f.reading() is None and f.held == 5.0)[-1])(
Freshness(BOUNDS["batt"])))
# ==============================================================================
print()
print("2. THE WIRING: the same pair through the REAL read path, at a roster of 2")
async def _reads(c, hass, plan):
"""Drive real `read_inputs()` calls. `plan` is (t, standing_a, batt_a)."""
out = []
for t, standing_a, batt_a in plan:
hass.numbers["sensor.batt_a"] = batt_a
# `last_write` is what the write path sets and what `standing_w()`
# reads; setting it here is standing in for a write, not for the gate.
c.inverters[0].last_write = standing_a
await c.read_inputs(now_mono=t)
out.append((t, c.inverters[0].batt, c.inverters[1].batt))
return out
_PLAN_FRESH = [(float(t), A_FROZEN_W, A_FROZEN_W) for t in range(0, 40)]
_PLAN_STALE = [(float(t), A_FROZEN_W if t < 1 else 1300.0, A_FROZEN_W)
for t in range(0, 40)]
_cf, _hf = site()
_of = run(_reads(_cf, _hf, _PLAN_FRESH))
_cs, _hs = site()
_os = run(_reads(_cs, _hs, _PLAN_STALE))
print(f" fresh run: A={sorted({r[1] for r in _of})} B={sorted({r[2] for r in _of})}")
print(f" stale run: A={sorted({r[1] for r in _os}, key=str)} "
f"B={sorted({r[2] for r in _os})}")
check("PRECONDITION: both runs were served the same per-entity numbers, so the "
"difference below is the wiring and not the stimulus",
[x for x in _hf.served if x[0] == "sensor.batt_a"]
== [x for x in _hs.served if x[0] == "sensor.batt_a"])
check("⚠️ THE TWO UNITS CARRY DIFFERENT POWER VALUES - A at -1200 W, B at "
"+900 W. A fixture whose units read the same number cannot show a gate "
"that answered for the wrong unit, which is the trap the card names",
A_FROZEN_W != B_LIVE_W and all(r[2] == B_LIVE_W for r in _of))
check("THROUGH THE REAL PATH: a converged site keeps its reading",
all(r[1] == A_FROZEN_W for r in _of))
check("⚠️ THROUGH THE REAL PATH: a frozen register becomes `Inverter.batt is "
"None`. Not a held number, not a zero - the same None a missing entity "
"produces, which is the state §11.1 rule 1 already answers with 0 W",
_os[34][1] == A_FROZEN_W and _os[35][1] is None
and all(r[1] is None for r in _os[35:]))
check("...and ONLY the frozen unit's. B's own reading is untouched, which is "
"the per-unit half: a freshness verdict about A is not evidence about B",
all(r[2] == B_LIVE_W for r in _os) and _cs.inverters[1].batt == B_LIVE_W
and not _cs.inverters[1].batt_fresh.stale)
check("...and `inputs_ok` goes False, so the site stops rather than regulating "
"against a number nobody measured",
_cs.inputs_ok is False and _cf.inputs_ok is True)
check("...and the operator gets a line saying which unit and why, on the same "
"status list the liveness notes use",
any("STALE_READING" in n and "inverter-a" in n
for n in _cs.liveness_notes())
and _cf.liveness_notes() == [])
check("...and the raw number is still recorded for diagnostics, so the "
"operator can see WHAT it was stuck on",
_cs.inverters[0].batt_fresh.last == A_FROZEN_W)
# ==============================================================================
print()
print("3. THE CONSUMERS REFUSE - and the counterfactual runs beside them")
# Both consumers already do the right thing with a None. Neither could ever
# receive one before this card, which is the entire exposure: the gate is the
# thing that was missing BETWEEN the shipped read and the shipped check.
_cfg3 = SignWatchdogConfig(min_w=250.0, hold_s=85.0)
check("PRECONDITION on the shipped predicate: a None measurement does not "
"disagree with anything, so it CLEARS the sign window",
disagrees(1300.0, None, True, _cfg3) is False
and disagrees(1300.0, A_FROZEN_W, True, _cfg3) is True)
check("PRECONDITION on the shipped sensor: a roster with a missing term is "
"None, and None clears the circulation window rather than accumulating",
circulating_w([1300.0, None]) is None
and circulating_w([A_FROZEN_W, B_LIVE_W]) == 2 * min(
abs(A_FROZEN_W), abs(B_LIVE_W)))
async def _cycles(gated, ticks=25, step_mono=12.0, wall=0.20):
"""Real cycles at N=2 with A's register frozen and the LOOP driving.
⚠️ THE COMMAND TRAVEL IS PRODUCED BY THE SHIPPED CONTROL LAW, NOT BY THE
FIXTURE. The meter is pinned at +3000 W of import, so `compute()` ramps the
site into discharge at its own slew rate through the real allocator and the
real write path; nothing here assigns `last_write`. A fixture that set the
standing command by hand would be feeding the gate its own arming condition,
which is the shape of check this repository keeps finding.
`gated=False` is THE COUNTERFACTUAL - the shipped behaviour before this
card, rebuilt by handing the consumers the raw held number the gate
withheld. Same cycles, same stimulus, so the difference between the two
columns is the gate and nothing else.
⚠️ TWO CLOCKS, DELIBERATELY, AND THE WALL-CLOCK ONE IS THE FLAKE RISK.
The freshness verdict runs on the INJECTED monotonic clock (12 s a tick, so
the 35 s bound is crossed on tick 3, deterministically, however loaded the
machine is). The sign watchdog runs on the WALL clock inside `cycle()`,
which cannot be injected - so `unit_sign_disagree_s` is set to 2 s and the
ticks are 0.20 s apart, putting the gate's close at ~0.6 s against a 2 s
window. That ORDER is the field's - 35 s < 75 s < 85 s by construction, part
6 - and the gate is given no head start beyond it.
⚠️ AND THE MARGIN IS MEASURED AND ASSERTED RATHER THAN ASSUMED (CI-06).
`test_peakguard.py` answered two of its own checks with the wall clock, flaked
at ~20 %, and cost a wrong merge diagnosis - a flaky check does not waste a
re-run, it manufactures evidence for whichever hypothesis the reader already
holds. So this returns the wall clock at which the gate closed, and the check
below fails with THAT NUMBER if the box was too slow, rather than reporting a
trip that looks like a code defect.
"""
c, hass = site({"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 2,
"sign_disagree_w": 250, "sign_disagree_s": 2,
"site_sign_disagree_w": 250, "site_sign_disagree_s": 2,
"auto_start": True},
{"sensor.grid": 3000.0})
c.auto = True
circ, tripped_at, standing = [], None, []
t_start, t_gate = time.monotonic(), None
for k in range(ticks):
# A's register is FROZEN on the charge figure it stopped at; B's is
# live and is a DIFFERENT number. Neither is touched by the loop.
hass.numbers["sensor.batt_a"] = A_FROZEN_W
hass.numbers["sensor.batt_b"] = B_LIVE_W
await c.read_inputs(now_mono=k * step_mono)
if not gated:
for inv in c.inverters:
inv.batt = inv.batt_fresh.last
if t_gate is None and c.inverters[0].batt_fresh.stale:
t_gate = time.monotonic() - t_start
circ.append(c.site_circulating_w)
standing.append(c.inverters[0].standing_w())
await c.cycle()
if c.inverters[0].sign_wd.tripped and tripped_at is None:
tripped_at = k
await asyncio.sleep(wall)
return c, circ, tripped_at, standing, t_gate
_cg, _circg, _tripg, _standg, _tgate = run(_cycles(True))
_cu, _circu, _tripu, _standu, _ = run(_cycles(False))
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
print(f" standing on A (gated): {[round(v) for v in _standg]}")
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
"on the real one, so this fixture only means something while the gate "
f"closes well inside the window: it closed at {_tgate:.2f} s against "
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
"saying so here is what stops that being read as a defect",
_tgate is not None and _tgate < 0.5 * _WD_S)
check("PRECONDITION: the SHIPPED control law really did travel the command - "
"the gate's arming term is produced by `compute()` and the allocator, "
"not written into the fixture",
max(abs(v) for v in _standu) > MOVE_W
and abs(_standu[3] - _standu[0]) > MOVE_W)
check("⚠️ COUNTERFACTUAL FIRST - the stimulus is REAL. Without the gate, the "
"frozen register false-trips SAFETY-30's per-unit sign check and parks a "
"healthy inverter. That is the fault this card was filed for",
_tripu is not None and _cu.inverters[0].sign_wd.tripped
and "SIGN_DISAGREEMENT" in _cu.inverters[0].sign_wd.reason)
check("⚠️ AND WITH THE GATE IT DOES NOT TRIP. The reading is withheld before "
"the watchdog's window can accumulate, so the check refuses to conclude "
"rather than concluding wrongly - which is what a stale reading deserves",
_tripg is None and not _cg.inverters[0].sign_wd.tripped)
check("...and inverter B was not parked either way - the verdict is per unit",
not _cg.inverters[1].sign_wd.tripped
and not _cu.inverters[1].sign_wd.tripped)
check("⚠️ COUNTERFACTUAL: without the gate the circulating-power sensor "
"reports a loop that is not happening, computed from one frozen term and "
"one live one",
all(v == 2 * min(abs(A_FROZEN_W), abs(B_LIVE_W)) for v in _circu)
and _circu[0] == 1800.0)
check("⚠️ AND WITH THE GATE IT REPORTS `None` - an unknown, which CLEARS the "
"circulation window instead of accumulating it. Both consumers gated, "
"from one place, by one mechanism",
_circg[0] == 1800.0 and _circg[-1] is None
and all(v is None for v in _circg[_circg.index(None):]))
check("...and the circulation watchdog did not latch on the stale evidence",
not _cg.circ_wd.tripped and _cg.circ_wd.over_since is None)
# ==============================================================================
print()
print("4. THE LATCH, and the ~1 Hz oscillation it exists to prevent")
# Declaring the reading stale makes inputs_ok False, which commands 0 W, which
# drops the standing command below MOVE_W, which un-arms the conjunction. Without
# a latch the held number would come straight back to the consumers, and the
# whole thing would flip once per read for as long as the register stayed dead.
_lat = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_lat.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_was = _lat.stale
_after_zero = []
for t in range(40, 50):
_lat.submit(A_FROZEN_W, 0.0, float(t)) # the site has been stopped
_after_zero.append(_lat.reading())
check("PRECONDITION: it is stale before the site is stopped",
_was and _lat.reading() is None)
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W, for RELEASE_S. The "
"conjunction is no longer armed - |standing| is 0 W, below the 250 W "
"term - so an unlatched verdict would hand the held -1200 W straight "
"back and the site would restart into it, then stop, then restart, once "
"per read. The ten reads here are inside the 17.0 s SAFETY-57 waits",
all(v is None for v in _after_zero) and len(_after_zero) == 10
and 10 < F.RELEASE_S)
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
# the whole sweep - no fixture had ever injected a read failure while latched.
# It matters: a stale verdict stops the site, the site is then commanded 0 W, and
# a single unreachable-Home-Assistant read would drop the latch into a state
# where the conjunction is no longer armed - so the held -1200 W would come back
# to the consumers on the very next successful read.
_lat_fail = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_lat_fail.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_lat_fail.submit(None, 0.0, 40.0) # Home Assistant did not answer
_after_fail = _lat_fail.reading()
_lat_fail.submit(A_FROZEN_W, 0.0, 41.0) # it answers again, same number,
# site now resting at 0 W
check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
"mutation, not by design. Our inability to fetch a value is not evidence "
"that the value moved, and a stale verdict that a single unreachable read "
"can drop is one a stopped site would drop every time",
_after_fail is None and _lat_fail.stale
and _lat_fail.reading() is None)
# ⚠️ THIS CHECK USED TO READ "AND ONLY MOVEMENT CLEARS IT ... Not a timer, not
# a restart, not the operator's start button", AND SAFETY-57 IS THE CARD THAT
# FALSIFIED IT. It was driven on `_lat` - already 20 s into a 0 W rest - so
# under the shipped exit it would now pass having never tested movement at all,
# which is habit 9: a check that goes on being true for a reason it does not
# name. It is rebuilt here on a FRESH object that has never rested, so the only
# thing that can clear it is the arrival.
_mov = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_mov.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_mov_was = _mov.stale
_mov.submit(A_FROZEN_W + 1.0, 1300.0, 40.0) # the register moves one watt
check("⚠️ MOVEMENT CLEARS IT WHILE THE OBLIGATION IS STILL STANDING - a "
"different number arriving under a command that has NOT rested, which is "
"exactly the evidence the verdict was about. Not a restart, not the "
"operator's start button. SAFETY-57's timer cannot be what cleared this "
"one: the command was +1300 W on every read, so the release clock never "
"started",
_mov_was and not _mov.stale and _mov.reading() == A_FROZEN_W + 1.0
and _mov.releases == 0 and _mov.rested_since is None)
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
# `_grade()` left every other check in this file green, because in all of them a
# command that has TRAVELLED 250 W has also ENDED UP above 250 W. The case that
# separates them is a site being brought to REST.
_rest = Freshness(BOUNDS["batt"])
# The register reaches +5 W on the same read at which the old +1300 W command is
# still standing - the write path is serialised, so the register can catch up
# BEFORE the command settles - and then the site is commanded to 0 W and the
# register sits at +5 W, correctly, for a minute.
_rest.submit(1290.0, 1300.0, 0.0)
_rest.submit(5.0, 1300.0, 1.0) # standing_at_grade captures 1300 W
for t in range(2, 60):
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
"to show it. A register that reached +5 W while the old +1300 W command "
"was still standing has a `standing_at_grade` of 1300 W, so the TRAVEL "
"term alone reads 1300 W of unfollowed command for the whole minute the "
"site rests - and calls a perfectly truthful reading dead. Requiring "
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
"movement'",
not _rest.stale and _rest.reading() == 5.0)
check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
"is why it is the right trade: a unit commanded under 250 W is one "
"`watchdog.disagrees()` is False for whatever the measurement says, and a "
"frozen term beside a live unit that is itself resting nets to zero "
"circulating power rather than to a false alarm",
disagrees(0.0, A_FROZEN_W, True, _cfg3) is False
and circulating_w([A_FROZEN_W, 0.0]) == 0.0)
check("...and one watt is enough, because 0x0518 is a 1 W register that "
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
"2026-08-22) - a working register clears this on its next 10 s publish",
_mov.held == A_FROZEN_W + 1.0 and not _mov.stale)
# ==============================================================================
print()
print("5. N=1: nothing changes on a healthy install, and what DOES is named")
_ONE = dict(BASE, inverters=[], setpoint_entity="number.sp_a",
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
bms_soc_floor=A_FLOOR)
def one_site():
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": 50.0,
"sensor.batt_a": A_FROZEN_W},
{"number.sp_a": (-A_RATING, A_RATING, 1.0)})
c = Controller(dict(_ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
c.inverters[0].dev_min, c.inverters[0].dev_max = -A_RATING, A_RATING
return c, hass
async def _one_healthy():
c, hass = one_site()
seen = []
for t in range(0, 60):
# A healthy register under a moving command: it dithers, so it is never
# the same integer watt twice running.
hass.numbers["sensor.batt_a"] = A_FROZEN_W + (t % 3)
c.inverters[0].last_write = 1300.0 if t % 2 else -1200.0
await c.read_inputs(now_mono=float(t))
seen.append(c.inverters[0].batt)
return c, seen
_c1, _seen1 = run(_one_healthy())
check("⚠️ AC: AT N=1 NOTHING CHANGES ON A HEALTHY INSTALL, and it is asserted "
"rather than assumed. Sixty reads of a dithering register under a command "
"reversing every second: every value reaches the consumers unmodified, "
"the gate never arms, and the status page says nothing new",
_seen1 == [A_FROZEN_W + (t % 3) for t in range(60)]
and not _c1.inverters[0].batt_fresh.stale
and _c1.liveness_notes() == [] and _c1.inputs_ok is True)
check("...and the shipped roster really is one unit, so this is the install "
"that is running in a house today and not a fixture shaped like it",
len(_c1.inverters) == 1 and _c1.inverters[0].name == "inverter-a")
async def _one_frozen():
c, hass = one_site()
for t in range(0, 40):
hass.numbers["sensor.batt_a"] = A_FROZEN_W
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
await c.read_inputs(now_mono=float(t))
return c
_c1f = run(_one_frozen())
check("⚠️ AND WHAT DOES CHANGE AT N=1 IS NAMED RATHER THAN HIDDEN: a FROZEN "
"register now stops the site, where before it read as a healthy "
"measurement. That is a behaviour change on the shipped roster and it is "
"the point of the card - HW-01 was a one-inverter site, and the pack "
"drained to its floor with every detector reading healthy for 6 h 25 m",
_c1f.inverters[0].batt is None and _c1f.inputs_ok is False)
check("...and the direction it fails in is the safe one: a false verdict "
"commands 0 W and keeps commanding (§11.1 rule 1), behind the existing "
"`stale_input_s` hold, and clears itself on the register's next move",
float(_c1f.o.get("stale_input_s")) > 0
and _c1f.inverters[0].batt_fresh.stale)
# ==============================================================================
print()
print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
def option_default(key):
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
body = body[body.index("\noptions:"):body.index("\nschema:")]
for line in body.splitlines():
if line.startswith(f" {key}:") and not line[2:3].isspace():
return float(line.split(":", 1)[1].strip())
return None
_circ_hold = option_default("circulation_hold_s")
_unit_hold = option_default("unit_sign_disagree_s")
_unit_min_w = option_default("unit_sign_disagree_w")
_ceiling = min(_circ_hold, _unit_hold)
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
print(f" bound {BATT_UNCHANGED_S:.1f} s")
print(f" exit {F.RELEASE_S:.1f} s (SAFETY-57, the same obligation window)")
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
f"unit_sign_disagree_s {_unit_hold:.0f})")
check("PRECONDITION: both consumer windows were actually found in config.yaml, "
"so a rename cannot turn this part into a check of two Nones",
_circ_hold == 75.0 and _unit_hold == 85.0 and _unit_min_w == 250.0)
check("⚠️ THE BOUND IS AT LEAST 2x THE PHYSICAL FLOOR. Below 17.0 s a healthy "
"register has not yet been OBLIGED to answer - 6.0 s of plant "
"(NOTES §5.9), one 10 s ESPHome publish (firmware/goodwe-master.yaml:181) "
"and one 1 s add-on read - so a shorter bound trips on a working inverter",
BATT_UNCHANGED_S >= 2 * BATT_OBLIGED_S and BATT_OBLIGED_S == 17.0)
check("⚠️ AND AT MOST HALF THE TIGHTER CONSUMER WINDOW. A gate slower than its "
"consumer hands over the frozen number and explains afterwards. THIS IS "
"THE CHECK THAT BITES IF AN OPERATOR'S DEFAULT MOVES: drop "
"`circulation_hold_s` to 60 in config.yaml and this goes red",
BATT_UNCHANGED_S <= 0.5 * _ceiling)
# ⚠️ THE FIRST DRAFT OF THIS CHECK CLAIMED THE BAND CONTAINED EXACTLY ONE
# INTEGER AND IT DOES NOT - it contains 34, 35, 36 and 37, and the check went
# red on its first run and is written the honest way round here. The band is
# what is derived; the value inside it is a choice, and the only defensible
# claims about the choice are that it IS inside and that it is not a number
# borrowed from somewhere else.
check("...and the admissible band computed from config.yaml is [34.0, 37.5], "
"which contains four integers. 35 is a CHOICE inside a DERIVED band, not "
"a derived number, and it is deliberately not 30 - which is "
"FIRMWARE_INGEST_VETO_S and would read as a borrowed constant - or 60, "
"which is round",
[n for n in range(0, 200)
if 2 * BATT_OBLIGED_S <= n <= 0.5 * _ceiling] == [34, 35, 36, 37]
and BATT_UNCHANGED_S not in (30.0, 60.0))
check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value "
"that still arms the gate everywhere the per-unit sign check can speak; "
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
"that check still fires in, which this catches",
MOVE_W == _unit_min_w)
check("⚠️ AND SAFETY-57'S EXIT SITS INSIDE THAT SAME BAND, AT ITS FLOOR - the "
"mirror of MOVE_W taking its own band's ceiling. The question a release "
"answers is 'has the register had its chance to answer the command "
"standing now?', which is the obligation window and nothing else, so the "
"exit is exactly the floor and strictly shorter than the bound whose "
"verdict it withdraws. An exit at or above the bound would mean the site "
"spends longer parked than the evidence that parked it took to gather",
F.RELEASE_S == BATT_OBLIGED_S and F.RELEASE_S < BATT_UNCHANGED_S
and BOUNDS["batt"].release_s == F.RELEASE_S)
check("⚠️ AND THE WHOLE PARKED INTERVAL IS NOW BOUNDED BY TWO SHIPPED NUMBERS "
"AND NOTHING ELSE: `stale_input_s` (15 s, config.yaml) then `release_s` "
"(17.0 s) = 32 s. The schema caps `stale_input_s` at 120, so even the "
"worst accepted configuration parks for 137 s - where the live episode "
"parked for 790 s and had no upper bound at all",
float(option_default("stale_input_s")) + F.RELEASE_S == 32.0
and "stale_input_s: int(5,120)" in io.open(
HERE / "config.yaml", encoding="utf-8").read()
and 120.0 + F.RELEASE_S == 137.0)
check("...and the module's three floor terms are the numbers this repository "
"actually carries, not a transcription of them: the ESPHome publish "
"interval is read back out of the shipped firmware",
"update_interval: 10s" in io.open(
HERE.parent / "firmware" / "goodwe-master.yaml",
encoding="utf-8").read()
and F.PUBLISH_PERIOD_S == 10.0 and F.READ_PERIOD_S == 1.0
and F.PLANT_SETTLE_S == 6.0)
# ==============================================================================
print()
print("7. THE POLICY TABLE tells the truth about what is WIRED")
def bounds_users(key):
"""Modules that SUBSCRIPT `BOUNDS[key]` in CODE - parsed, not grepped.
⚠️ THE FIRST DRAFT GREPPED FOR THE STRING AND ANSWERED WRONG, because both
`inverter.py` and `main.py` NAME `BOUNDS["soc"]` in a comment explaining why
it is not wired. A text search cannot tell a mention from a use, and this
part's whole job is to tell them apart - so it walks the AST instead.
"""
out = []
for path in sorted((HERE / "app").glob("*.py")):
if path.name == "freshness.py":
continue
tree = ast.parse(io.open(path, encoding="utf-8").read())
for node in ast.walk(tree):
if (isinstance(node, ast.Subscript)
and isinstance(node.slice, ast.Constant)
and node.slice.value == key
and ((isinstance(node.value, ast.Name)
and node.value.id == "BOUNDS")
or (isinstance(node.value, ast.Attribute)
and node.value.attr == "BOUNDS"))):
out.append(path.name)
break
return out
_uses_batt = bounds_users("batt")
_uses_soc = bounds_users("soc")
for _k, _b in BOUNDS.items():
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
f"move={_b.move_w:>6} release={_b.release_s:>6} {_b.signal}")
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody "
"checks is a comment; this is the row `batt` claims and the module that "
"claims it",
BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"])
check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is "
"STATED here rather than wired, because its obligation term is energy "
"through the pack and the constant that needs has never been measured on "
"this site. A guessed constant would fire on a pack resting at its floor",
BOUNDS["soc"].wired is False and _uses_soc == [])
check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
"next reader does not have to reconstruct why it is unwired",
"energy" in BOUNDS["soc"].derivation
and "153.6 Wh" in BOUNDS["soc"].derivation
and "TEL-08" in BOUNDS["soc"].derivation)
check("...and every wired bound is a real number IN ALL THREE COLUMNS - an "
"infinite bound is how a row silently becomes decorative, and SAFETY-57's "
"`release_s` is the newest place that can happen: at `inf` the module is "
"byte-for-byte the one that parked the live site for 790 s",
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
and b.release_s < float("inf")
for b in BOUNDS.values() if b.wired))
check("...and the UNWIRED row is infinite in all three, so nothing about "
"SoC's absent bound can be read as a policy that exists",
all(b.unchanged_s == b.move_w == b.release_s == float("inf")
for b in BOUNDS.values() if not b.wired))
# ==============================================================================
print()
print("8. THE MEASUREMENT the bound rests on, replayed from the committed data")
# ⚠️ THIS IS THE PART THAT STOPS THE DERIVATION BECOMING A STORY. The docstring
# in freshness.py quotes three numbers off this capture; they are recomputed
# here from the committed files, so a figure that drifts from its evidence goes
# red instead of being re-quoted forever.
#
# ⚠️ AND IT IS THE METER'S REGISTER, NOT 0x0518. No 30-day capture of the
# inverter's own power exists. What this establishes is the only thing it is
# used for: that the legitimate unchanged duration of a 1 W power entity in this
# house is MINUTES, so an unchanged threshold alone has no admissible value.
_cap = sorted(glob.glob(str(HERE / "scenarios" / "p1-net-30d-2026-08"
/ "p1-net-*.jsonl.gz")))
_runs, _prev = [], None
_t0 = time.monotonic()
for _f in _cap:
with gzip.open(_f, "rt") as _fh:
for _line in _fh:
_r = json.loads(_line)
_t = datetime.fromisoformat(_r["t"]).timestamp()
if _prev is not None:
_runs.append((_t - _prev, _pw))
_prev, _pw = _t, float(_r["w"])
_worst = max(_runs)[0] if _runs else 0.0
_over_bound = sum(1 for d, _ in _runs if d >= BATT_UNCHANGED_S)
print(f" {len(_cap)} days, {len(_runs) + 1} samples, "
f"{time.monotonic() - _t0:.1f}s to replay")
print(f" longest unchanged run {_worst:.1f} s; "
f"{_over_bound} runs >= {BATT_UNCHANGED_S:.0f} s")
check("PRECONDITION: the committed capture is all thirty days, so this is not "
"a claim about whatever files happened to be present",
len(_cap) == 30 and len(_runs) + 1 == 485841)
check("⚠️ A REAL 1 W POWER ENTITY HELD ONE VALUE FOR 263.2 s WITH NOTHING "
"WRONG. That single measurement is why the bound is a conjunction and "
"not a threshold: no unchanged threshold under 263 s is free of false "
"positives, and 263 s is 3.5x too slow to protect a 75 s consumer",
abs(_worst - 263.2) < 0.1 and _worst > _ceiling)
check("...and the two figures freshness.py's docstring quotes off this capture "
"are the ones it produces today",
_over_bound == 10
and sum(1 for d, _ in _runs if d >= BATT_OBLIGED_S) == 386)
check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what the "
"conjunction keys on. The worst one is 263 s of a steady -899 W export - "
"a house doing one thing - so a gate that also requires the COMMAND to "
"have travelled 250 W is not armed by any of them",
max(_runs)[1] == -899.0)
# --- 8b. AND THE EXIT THE BOUND USED TO HAVE, ON THE SAME HOUSE -------------
# ⚠️ THIS IS THE MEASUREMENT SAFETY-57 RESTS ON, and it is about the REGISTER
# rather than about any code: before this card the only way out of the verdict
# was "an arrival carrying a different number", so the length of the state IS
# the time to 0x0518's next change. The committed 16-day loop capture is
# replayed at 1 Hz through the SHIPPED conjunction to find the trips, and each
# trip is then measured against the register's own next move.
#
# ⚠️ WHAT THIS REPLAY IS AND IS NOT. The capture runs 2026-09-02..17 and 0.10.0
# - the first version carrying this gate - was cut on 2026-09-16, so the
# setpoint column is an UNGATED controller's. That makes each trip an honest
# register-versus-command divergence off the real bus, and it makes the
# post-trip command column something a gated box would NOT have written. The
# figure taken from it is therefore only the one that does not depend on the
# command at all: how long this register goes without changing value. If
# anything it UNDER-states the wait, because a site parked at 0 W gives the
# register even less reason to move than the one recorded here.
_LOOPDIR = HERE / "scenarios" / "loop-2026-09-02_2026-09-17"
_loop_days = sorted({Path(f).name.rsplit("-", 1)[0]
for f in glob.glob(str(_LOOPDIR / "*-batt.jsonl.gz"))})
def _loop_series(day, sig):
out = []
with gzip.open(_LOOPDIR / f"{day}-{sig}.jsonl.gz", "rt") as fh:
for line in fh:
row = json.loads(line)
try:
v = float(row["v"])
except ValueError:
continue # 'unavailable' / 'unknown' are not readings
out.append((datetime.fromisoformat(row["t"]).timestamp(), v))
return out
_t8b = time.monotonic()
_waits, _trips8b = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
_trips8b += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
_waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
_waits.sort()
_p50 = _waits[len(_waits) // 2]
print(f" {len(_loop_days)} days replayed at 1 Hz in "
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
print(f" wait for the register's next change after a trip: "
f"min {_waits[0]:.1f} s median {_p50:.1f} s max {_waits[-1]:.1f} s; "
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
f"710 s")
check("PRECONDITION: all sixteen committed days were replayed, not whichever "
"files happened to be present",
len(_loop_days) == 16 and _loop_days[0] == "2026-09-02"
and _loop_days[-1] == "2026-09-17" and len(_waits) == _trips8b)
check("⚠️ THE OLD EXIT WAS NOT RARE AND IT WAS NOT SHORT. Ninety-three trips in "
"sixteen days of this house, and the only thing that could clear any of "
"them - the register carrying a different number - took a MEDIAN of "
"143.4 s and as long as 21440.1 s. Seventeen of the ninety-three are at "
"or beyond the 710 s the live site was parked for on 2026-09-21",
_trips8b == 93 and abs(_p50 - 143.4) < 0.1
and abs(_waits[-1] - 21440.1) < 0.1
and sum(1 for w in _waits if w >= 710.0) == 17)
check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE - "
"which is the whole reason the exit could not stay as it was. The short "
"waits are the pack's keep-alive pulse twitching 0x0518 and satisfying "
"the clearing rule BY ACCIDENT: a quarter of them are under 20 s, and "
"nothing in this add-on asked for any of them. After SAFETY-57 the wait "
"is `stale_input_s + release_s` = 32 s whatever the register does",
sum(1 for w in _waits if w < 20.0) >= 20
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
# ==============================================================================
print()
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
# ⚠️ THE REPO'S RECURRING DEFECT IS A CHECK WHOSE DATA CANNOT REACH THE FAILURE
# IT NAMES. The honest end of that habit is to build the mutations this file
# would NOT see and record them. Each one below is a real hole in the shipped
# gate, demonstrated rather than described.
# S1. A register pinned by the INVERTER's own limit, not by a fault. If the
# device is derating and cannot follow a command that ASKS FOR MORE, the
# register can legitimately hold while the command travels.
_s1 = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_s1.submit(2503.0, 2650.0 if t < 1 else 4600.0, float(t))
check("⚠️ SURVIVOR 1, FALSE POSITIVE: a saturated or derating inverter pinned "
"at 2503 W while the command climbs to 4600 W is declared stale. The "
"register is telling the truth and the gate calls it dead",
_s1.stale)
check("...and its SIZE is bounded and its direction is the safe one: the "
"verdict is a site stop, behind the `stale_input_s` hold, and the moment "
"the command comes back down the register moves and the latch clears. "
"Cost = one spurious stop; it cannot drive the battery anywhere",
(lambda f: (f.submit(2400.0, 0.0, 60.0), not f.stale)[-1])(_s1))
# S2. A unit whose command never travels 250 W. HW-01's own shape, at a
# smaller scale, and the gate is deliberately not armed for it.
_s2 = Freshness(BOUNDS["batt"])
for t in range(0, 400):
_s2.submit(A_FROZEN_W, -1200.0 if t < 1 else -1400.0, float(t))
check("⚠️ SURVIVOR 2, MISS: a register frozen under a command that only ever "
"moves 200 W is NEVER detected, for 400 s or forever. This is scoped, "
"not overlooked - such a unit is one no per-unit sign check can speak "
"about (`unit_sign_disagree_w` is 250 W) and one contributing at most "
"250 W to a loop whose reporting floor is 700 W",
not _s2.stale and _s2.reading() == A_FROZEN_W)
# S3. A register that dithers on dead data. A publisher that keeps republishing
# a stale WINDOW of values rather than one value defeats this entirely.
_s3 = Freshness(BOUNDS["batt"])
for t in range(0, 400):
_s3.submit(A_FROZEN_W + (t % 2), 1300.0, float(t))
check("⚠️ SURVIVOR 3, MISS, AND IT IS THE LARGEST ONE: a dead publisher whose "
"last value happens to ALTERNATE - or a cache replaying two old samples - "
"never triggers the unchanged clock at all. This gate detects a value "
"that STOPPED; it cannot detect a value that is WRONG. Nothing in this "
"add-on can, and TEL-06's arrival stamp inside the firmware is the only "
"layer that could",
not _s3.stale and _s3.reading() is not None)
# S4. The gate is per unit, so a whole-site publisher failure that freezes both
# registers is caught twice rather than missed - recorded because a reader
# might expect the opposite.
_s4a, _s4b = Freshness(BOUNDS["batt"]), Freshness(BOUNDS["batt"])
for t in range(0, 40):
_s4a.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
_s4b.submit(B_LIVE_W, B_LIVE_W if t < 1 else -1300.0, float(t))
check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
"that freezes BOTH registers is caught on both units independently, on "
"each unit's own command travel and each unit's own number",
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
# The unchanged clock keeps running while the site rests, so the FIRST read
# at which a command travels 250 W after a long quiet already satisfies all
# three terms - the register is given no time at all to answer the new
# command. Present on the shipped tree; the live capture in part 10 contains
# one (10:48:14, cleared one second later).
_s5 = Freshness(BOUNDS["batt"])
for t in range(0, 300): # five minutes of a resting site
_s5.submit(-3.0, 0.0, float(t))
_s5_quiet = _s5.stale
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
"long rest the unchanged clock is already past 35 s, so the very FIRST "
"read carrying a travelled command trips the gate - before the 17.0 s in "
"which a healthy register could have answered it. The gate accuses on "
"read one and the register answers on read eleven",
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
"here rather than fixed here: `cycle()` returns at `inputs missing "
"{n}s` for the whole of it, so nothing is commanded differently, and the "
"register's next publish clears it. Fixing it means measuring the "
"unchanged clock from when the OBLIGATION appeared rather than from the "
"register's last move, which re-derives the 35.0 s bound and is a "
"different card",
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
and float(option_default("stale_input_s")) >= 5.0)
# ==============================================================================
print()
print("10. SAFETY-57: THE EXIT. The state the verdict creates, and the way out")
# ⚠️ THE ACCEPTANCE CRITERION OF THIS CARD, IN ONE SENTENCE, AND EVERY CHECK
# BELOW IS GRADED AGAINST IT: a scenario in which the register is held CONSTANT
# and the command is gated must recover WITH NO CHANGE TO THE REGISTER. Parts 1
# to 9 grade whether the gate can be ENTERED. A check that only shows a latch
# latching is decoration for this card.
#
# ⚠️ THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS NOT A TRANSCRIPTION.
# `_release()` with an infinite bound never fires, `_open_window()` is then only
# ever called where the register moves, and `graded_from`/`standing_at_grade`
# are `changed_mono`/the command at that change - which is byte-for-byte the
# pre-SAFETY-57 conjunction. That equivalence is ASSERTED in 10b against part
# 1's own fixtures rather than claimed here, because a counterfactual nobody
# graded is a second implementation.
_BEFORE = dataclasses.replace(BOUNDS["batt"], release_s=float("inf"))
def _decoder():
"""The committed decoder, loaded from `tools/` rather than transcribed."""
spec = importlib.util.spec_from_file_location(
"_s57decode", HERE / "tools" / "decode_bus_capture.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def hms(t):
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{int(t % 60):02d}"
def _raises(fn):
try:
fn()
except ValueError:
return True
return False
# `stale_input_s` is READ from the shipped config.yaml, not typed here: the
# parked half of every period below is that option, and a check that carried its
# own copy would go on being true after an operator moved it.
_S57_HOLD = float(option_default("stale_input_s"))
def _dead_site(bound, reads=1201, dead_w=A_FROZEN_W, hold_s=None):
"""A register frozen for good, with §11.1 rule 1 driving the command.
Returns `(freshness, exposure)` where `exposure[i]` is True when read `i`
handed the consumers a number. The command rule is the one `cycle()` uses -
hold the standing command for `stale_input_s`, then 0 W - and the loop's
own ask when telemetry is available; it is written here rather than driven
through `cycle()` because that hold runs on the WALL clock and the bound
under test runs on the injected one. 10g drives the real path for one
period instead.
"""
hold_s = _S57_HOLD if hold_s is None else hold_s
f = Freshness(bound)
standing, bad_since, exposure = dead_w, None, []
for t in range(0, reads):
f.submit(dead_w, standing, float(t))
live = f.reading() is not None
exposure.append(live)
if live:
bad_since = None
standing = 1300.0 if t >= 1 else dead_w
else:
bad_since = t if bad_since is None else bad_since
standing = standing if t - bad_since < hold_s else 0.0
return f, exposure
def _run_lengths(exposure):
"""The lengths of the unbroken runs of True in `exposure`."""
out, n = [], 0
for live in exposure:
if live:
n += 1
elif n:
out.append(float(n))
n = 0
if n:
out.append(float(n))
return out
def _release_instant(release_s):
"""Seconds after the command rests at which the verdict is withdrawn.
One stimulus, one varied term (habit 7): the same 40 s of unfollowed
+1300 W command, then a resting site, under each candidate bound.
"""
f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=release_s))
for t in range(0, 40):
f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
for t in range(40, 200):
f.submit(A_FROZEN_W, 0.0, float(t))
if not f.stale:
return float(t) - 40.0
return None
# --- 10a. the capture, and what it is allowed to be evidence of --------------
_D = _decoder()
_CAPDIR = HERE / "scenarios" / "safety57-2026-09-21"
_CAPFILE = _CAPDIR / "bus-a-2026-09-21-1030-1110.log.gz"
_man = json.loads(io.open(_CAPDIR / "manifest.json", encoding="utf-8").read())
_events, _census = _D.decode(_CAPFILE)
_sp = _D.series(_events, "setpoint_w")
_bt = _D.series(_events, "batt_w")
_soc = _D.series(_events, "soc_pct")
print(f" capture {_CAPFILE.name}: {_census}")
print(f" setpoint {len(_sp)} frames batt {len(_bt)} frames "
f"soc {len(_soc)} frames, "
f"{hms(_sp[0][0])}..{hms(_sp[-1][0])} Europe/Brussels")
check("PRECONDITION: the committed capture is the file the manifest names, "
"byte for byte. A scenario whose bytes can drift from the numbers quoted "
"off them is a story",
hashlib.sha256(io.open(_CAPFILE, "rb").read()).hexdigest()
== _man["file"]["sha256_gz"]
and _man["file"]["bytes_gz"] == _CAPFILE.stat().st_size)
check("PRECONDITION: every line of it was understood. 3840 frames, and the "
"decoder's own census reports ZERO unparsed, ZERO bad CRC and ZERO "
"unknown shapes - so nothing in the episode below was dropped on the "
"floor by the instrument",
_census["lines"] == 3840 and _census["unparsed"] == 0
and _census["bad_crc"] == 0 and _census["unknown"] == 0
and _census["writes"] == 1200 and _census["responses"] == 720)
# ⚠️ THE SIGN IS THE ONE THING THIS DECODER COULD GET EXACTLY BACKWARDS, and a
# backwards sign would invert the whole episode - a discharge the BMS refused
# would read as a charge it accepted. So the mode/sign correspondence is GRADED
# OFF THE CAPTURE rather than trusted: every charge command is followed by a
# negative register reading, and there is no reading of the other sign.
_after_charge = [
_D.held_at(_bt, t + F.BATT_OBLIGED_S)
for t, v in _sp if v <= -1000.0]
_after_charge = [v for v in _after_charge if v is not None]
print(f" readings {F.BATT_OBLIGED_S:.0f} s after a <= -1000 W command: "
f"{len(_after_charge)} of them, "
f"max {max(_after_charge):+.0f} W, min {min(_after_charge):+.0f} W")
check("⚠️ THE DECODER'S SIGN CONVENTION IS GRADED, NOT ASSERTED. EMS mode 2 is "
"read as charge and mode 3 as discharge; if that were backwards the "
"episode would read as a refused CHARGE. Every command at or under "
"-1000 W in this capture is answered, one obligation window later, by a "
"NEGATIVE register reading - and not one positive one",
len(_after_charge) > 200 and max(_after_charge) < 0.0
and _D.signed_setpoint_w(2, 2170) == -2170.0
and _D.signed_setpoint_w(3, 1060) == +1060.0)
check("...and an EMS mode this decoder has never seen RAISES rather than "
"defaulting to a sign. A silent default is how a firmware change becomes "
"a mis-signed series nobody re-derives",
_raises(lambda: _D.signed_setpoint_w(1, 500))
and _raises(lambda: _D.signed_setpoint_w(0, 0)))
# --- 10b. THE LIVE EPISODE, RED BEFORE AND GREEN AFTER -----------------------
def _replay_capture(bound):
"""The shipped `_read_unit` loop, at 1 Hz, against the capture.
⚠️ THE COMMAND COLUMN IS NOT A MODEL. The capture was taken off a box
running 0.10.0, i.e. running the defect: the 0 W at 10:52:21 IS the site's
own `inputs missing -> command 0 W`, recorded on the wire. So this replay is
closed-loop for the whole of the parked interval, and the only thing in the
window it cannot account for is the human restart at ~11:04.
"""
f = Freshness(bound)
t0 = int(max(_sp[0][0], _bt[0][0])) + 1
t1 = int(min(_sp[-1][0], _bt[-1][0]))
rows, t = [], float(t0)
while t <= t1:
f.submit(_D.held_at(_bt, t), _D.held_at(_sp, t), t)
rows.append((t, f.stale, f.held, _D.held_at(_sp, t), f.releases))
t += 1.0
return f, rows
def _edges(rows):
return [r for i, r in enumerate(rows) if i == 0 or r[1] != rows[i - 1][1]]
_f_before, _rows_before = _replay_capture(_BEFORE)
_f_after, _rows_after = _replay_capture(BOUNDS["batt"])
# ⚠️ THE EPISODE'S BOUNDARIES ARE READ OFF THE CAPTURE, NOT TYPED. It is
# defined by the thing it is about - the register's LONGEST unbroken hold in
# the window - and every other instant is derived from that one. A fixture that
# named 10:52:00 by hand would go on naming it after the file changed.
# ⚠️ CHANGE POINTS, NOT FRAMES. 0x0518 is POLLED every 10 s and answers every
# poll, so consecutive frames are always 10 s apart whatever the register is
# doing - a gap computed over frames measures the poll period and nothing else,
# which is `freshness.py`'s own first paragraph in miniature.
_bt_moves = [r for i, r in enumerate(_bt) if i == 0 or r[1] != _bt[i - 1][1]]
_holds = [(_bt_moves[i + 1][0] - _bt_moves[i][0], _bt_moves[i][0],
_bt_moves[i + 1][0], _bt_moves[i][1])
for i in range(len(_bt_moves) - 1)]
_HOLD_S, _HOLD_FROM, _REG_MOVES, _HOLD_W = max(_holds)
_TRIP = next(t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM)
_PARK = next(t for t, v in _sp if t > _TRIP and v == 0.0)
_HUMAN = next(t for t, v in _sp if t > _PARK and v != 0.0)
_blips = [t for t, s, _h, _sw, _r in _edges(_rows_after)
if s and t < _HOLD_FROM]
print(f" longest register hold {_HOLD_S:.1f} s at {_HOLD_W:+.0f} W, "
f"{hms(_HOLD_FROM)}..{hms(_REG_MOVES)}")
print(f" trip {hms(_TRIP)} site commands 0 W {hms(_PARK)} "
f"human restart's first command {hms(_HUMAN)} "
f"register moves {hms(_REG_MOVES)}")
print(f" earlier one-read trips in this window (survivor 5): "
f"{[hms(t) for t in _blips]}")
def _first_clear_after(rows, t_from):
return next((t for t, s, _h, _sw, _r in rows if t > t_from and not s), None)
_clear_before = _first_clear_after(_rows_before, _TRIP)
_clear_after = _first_clear_after(_rows_after, _TRIP)
print(f" BEFORE (release_s=inf): stale {hms(_TRIP)} -> clear "
f"{hms(_clear_before)} {_clear_before - _TRIP:.0f} s")
print(f" AFTER (release_s={BOUNDS['batt'].release_s:.0f}): stale "
f"{hms(_TRIP)} -> clear {hms(_clear_after)} "
f"{_clear_after - _TRIP:.0f} s")
check("PRECONDITION: `release_s=inf` REPRODUCES the pre-SAFETY-57 module and is "
"not a second implementation of it. Part 1's own fresh/stale pair, driven "
"through both bounds, gives bit-identical verdict streams - the release "
"is the only behaviour the counterfactual removes",
drive(_BEFORE, STREAM, lambda t: A_FROZEN_W)[2] == _fresh_read
and drive(_BEFORE, STREAM,
lambda t: A_FROZEN_W if t < 1 else 1300.0)[2] == _stale_read)
check("PRECONDITION: the episode is in the capture where the manifest says it "
"is, and it is the window's WORST hold rather than one picked out of "
"several. 0x0518 answers -3 W for 850.0 s from 10:51:24, the gate arms "
"at 10:52:00, the site's own 0 W reaches the wire at 10:52:21, and "
"nothing else moves until a human does at 11:05:31",
abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0
and hms(_HOLD_FROM) == "10:51:24" and hms(_TRIP) == "10:52:00"
and hms(_PARK) == "10:52:21" and hms(_HUMAN) == "11:05:31"
and hms(_REG_MOVES) == "11:05:34"
and sorted(_holds)[-2][0] < 0.5 * _HOLD_S)
check("⚠️ RED BEFORE. On the shipped-before module the verdict outlives the "
"whole parked interval: from the trip to the first command a HUMAN put "
"on the wire, 811 s later, `reading()` is None on every one of the 811 "
"reads - and the register never changes value in any of them, so there "
"is nothing the module could have cleared on",
all(s for t, s, _h, _sw, _r in _rows_before if _TRIP <= t < _HUMAN)
and len([1 for t, s, _h, _sw, _r in _rows_before
if _TRIP <= t < _HUMAN]) == 812
and {v for t, v in _bt if _TRIP <= t < _HUMAN} <= {-3.0}
and _clear_before > _HUMAN)
check("⚠️ AND WHAT ENDS IT ON THE SHIPPED-BEFORE MODULE IS OUTSIDE THE MODULE. "
"The clear lands three seconds after the register moves, and the register "
"moves because a person restarted the add-on and the restarted loop "
"commanded a CHARGE the pack accepted. Nothing inside this add-on "
"produced that command",
_clear_before > _REG_MOVES and _clear_before - _REG_MOVES <= 2.0
and _HUMAN < _REG_MOVES)
check("⚠️ GREEN AFTER, AND THE REGISTER NEVER MOVES - THIS IS THE CARD. The "
"verdict is withdrawn 17 s after the site's own 0 W reaches the wire, "
"while 0x0518 is still answering the same -3 W it has answered since "
"10:51:24, and `reading()` is a number again with no external change of "
"any kind",
_PARK + BOUNDS["batt"].release_s <= _clear_after
< _PARK + BOUNDS["batt"].release_s + 1.0
and hms(_clear_after) == "10:52:39" and _f_after.releases >= 1
and _D.held_at(_bt, _clear_after) == _D.held_at(_bt, _TRIP) == -3.0
and next(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0)
check("...and the recovery is worth 776 s of this one episode: the site is "
"handed its telemetry back at 10:52:39 instead of at 11:05:35, and 772 "
"of those seconds are BEFORE the human touched anything. That is the "
"13 minutes it spent parked while the house exported 2.4 kW",
_clear_before - _clear_after == 776.0
and abs((_HUMAN - _clear_after) - 772.7) < 0.01
and all(not s for t, s, _h, _sw, _r in _rows_after
if _clear_after <= t < _HUMAN))
check("⚠️ AND THE RELEASE STICKS RATHER THAN CHATTERING. From the withdrawal to "
"the human's first command the verdict is withdrawn on every one of the "
"773 reads, and exactly ONE release was taken in the whole episode - the "
"1 Hz oscillation the latch exists to prevent is still prevented, "
"because the window a release opens is 35 s wide",
len([1 for t, s, _h, _sw, _r in _rows_after
if _clear_after <= t < _HUMAN]) == 773
and _f_after.releases == 1)
# --- 10c. what a release costs, measured -------------------------------------
# ⚠️ THE EXPOSURE IS THE NUMBER THE SAFETY ARGUMENT RESTS ON, so it is counted
# rather than reasoned about: how many reads hand a consumer the held number
# after a release, and how long the longest unbroken run of them is. The whole
# argument is that one run is shorter than either consumer's hold.
_dead, _exp57 = _dead_site(BOUNDS["batt"])
_deadb, _expb = _dead_site(_BEFORE)
_runs57 = _run_lengths(_exp57)
print(f" register dead for good, {len(_exp57)} reads, "
f"stale_input_s={_S57_HOLD:.0f}")
print(f" before : held number reached a consumer on {sum(_expb)} reads "
f"({100 * sum(_expb) / len(_expb):.1f} %), runs={_run_lengths(_expb)}")
print(f" after : held number reached a consumer on {sum(_exp57)} reads "
f"({100 * sum(_exp57) / len(_exp57):.1f} %), releases={_dead.releases}, "
f"longest run={max(_runs57):.0f} s")
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
"that is dead for good is re-graded for ever: over 1201 reads the held "
"number reaches a consumer on 630 of them against 35 before, and 17 "
"releases are taken. This check exists so that cost cannot be quietly "
"forgotten, and so a change that makes it worse has to move a number "
"somebody reads",
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
and _deadb.releases == 0)
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
"one, and the re-trip that ends each run hands them a None, which CLEARS "
"their windows rather than accumulating into them",
set(_runs57) == {BOUNDS["batt"].unchanged_s}
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
check("...and nothing SAFETY-37 detects is given back by that, which is the "
"half that makes the trade a trade. A permanent None clears both windows "
"for ever too, so on the shipped-before module the same two detectors are "
"equally unable to speak about this unit. The exposure buys the site its "
"meter-derived control back and costs no detection that existed",
disagrees(1300.0, None, True, _cfg3) is False
and circulating_w([1300.0, None]) is None)
# --- 10d. the bound, graded as a family --------------------------------------
# ⚠️ ASSERTING `release_s == BATT_OBLIGED_S` WOULD BE COMPARING THE SHIPPED
# EXPRESSION AGAINST ITSELF (habit 2, and MAINT-05's whole card). The literal is
# asserted, the three terms it is built from are asserted, and then the BAND is
# graded behaviourally against its neighbours.
print(f" RELEASE_S {F.RELEASE_S:.1f} s = settle {F.PLANT_SETTLE_S:.1f} "
f"+ publish {F.PUBLISH_PERIOD_S:.1f} + read {F.READ_PERIOD_S:.1f}")
check("⚠️ THE BOUND IS 17.0 s AND IT IS THE PHYSICAL OBLIGATION WINDOW, "
"asserted against the literal rather than against the expression that "
"produces it. Same three terms as the floor of `BATT_UNCHANGED_S`'s "
"band, because it is the same question: has the register had its chance "
"to answer the command standing NOW?",
BOUNDS["batt"].release_s == 17.0 and F.RELEASE_S == 17.0
and F.PLANT_SETTLE_S + F.PUBLISH_PERIOD_S + F.READ_PERIOD_S == 17.0)
_family = {r: _release_instant(r) for r in (5.0, 16.0, 17.0, 18.0, 35.0,
float("inf"))}
for _r, _at in sorted(_family.items(), key=lambda kv: kv[0]):
print(f" release_s {_r:>5} -> withdrawn at t+{_at}"
if _at is not None else f" release_s {_r:>5} -> never")
check("⚠️ THE FAMILY IS COMPUTED AND PRINTED, so a bound no fixture separates "
"shows up as a named survivor. Each candidate withdraws the verdict "
"exactly its own number of seconds after the command rests, and they are "
"six DISTINCT answers - a check that could not tell 17 from 35 could not "
"tell this card from a slower version of the bug",
_family == {5.0: 5.0, 16.0: 16.0, 17.0: 17.0, 18.0: 18.0, 35.0: 35.0,
float("inf"): None})
# ⚠️ AND THE FLOOR IS GRADED BEHAVIOURALLY, WHICH IS THE HALF A CONSTANT CHECK
# CANNOT DO. Below 17.0 s the timer fires before a healthy register could have
# published its answer to the 0 W command - so it takes the expensive exit when
# the free one was one read away. The register here answers at the obligation
# window's own edge, which is the case the bound is derived from.
_free_exit = {}
for _r in (5.0, 17.0):
_f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=_r))
for t in range(0, 40):
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
for t in range(40, 40 + int(F.BATT_OBLIGED_S) - 1):
_f.submit(A_FROZEN_W, 0.0, float(t)) # resting, register not yet in
_f.submit(-1150.0, 0.0, float(40 + int(F.BATT_OBLIGED_S) - 1))
_free_exit[_r] = (_f.releases, _f.stale, _f.held)
print(f" register answers the 0 W command at +16 s: {_free_exit}")
check("⚠️ THE FLOOR BITES, AND IT BITES ON BEHAVIOUR. A register that answers "
"the new 0 W command inside its own obligation window clears the latch "
"BY MOVING - the free exit - and at 17.0 s that is what happens, with "
"`releases` still 0. At 5.0 s the timer has already fired and the site "
"has paid for a re-grading window it did not need",
_free_exit[17.0][0] == 0 and _free_exit[5.0][0] == 1
and _free_exit[17.0][2] == _free_exit[5.0][2] == -1150.0)
# --- 10e. the release does not rewrite the register's history ----------------
_hist = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_hist.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
for t in range(40, 120):
_hist.submit(A_FROZEN_W, 0.0, float(t))
print(f" at t=119 after one release: unchanged_s={_hist.unchanged_s(119.0)} "
f"graded_s={_hist.graded_s(119.0)} releases={_hist.releases}")
check("⚠️ TWO CLOCKS, AND THE ONE THE OPERATOR READS IS THE REGISTER'S. A "
"release re-opens the VERDICT's window; it does not make the register "
"younger. At t=119 the register has not carried a different number for "
"119 s and `unchanged_s` says 119 s, while the grading window opened at "
"the release at t=57 and says 62 s. A single clock here would either lie "
"to the operator or defeat the re-grading",
_hist.unchanged_s(119.0) == 119.0 and _hist.graded_s(119.0) == 62.0
and _hist.releases == 1 and _hist.held == A_FROZEN_W
and _hist.changed_mono == 0.0 and _hist.graded_from == 57.0)
# --- 10f. the obligation coming back cancels the timer -----------------------
_resume = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_resume.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
for t in range(40, 40 + int(BOUNDS["batt"].release_s) - 1):
_resume.submit(A_FROZEN_W, 0.0, float(t)) # 16 s of rest
_resume.submit(A_FROZEN_W, 1300.0, 56.0) # the command comes back
_resume_mid = (_resume.stale, _resume.rested_since)
for t in range(57, 57 + int(BOUNDS["batt"].release_s) - 1):
_resume.submit(A_FROZEN_W, 1300.0, float(t)) # and stays
check("⚠️ A TIMER THAT DOES NOT RESET IS NOT A TIMER. Sixteen seconds of rest, "
"one second short of the bound, and then the site puts the obligation "
"back: the clock is cleared, not carried, so the verdict stands. "
"Carrying it would withdraw a verdict about a command that is standing "
"RIGHT NOW",
_resume_mid == (True, None) and _resume.stale
and _resume.releases == 0 and _resume.reading() is None)
_resume.submit(A_FROZEN_W, 0.0, 80.0)
for t in range(81, 81 + int(BOUNDS["batt"].release_s)):
_resume.submit(A_FROZEN_W, 0.0, float(t))
check("...and it starts again from the SECOND rest, not from the first: the "
"release lands 17.0 s after t=80, which is 41 s after the first rest "
"began. The total time spent resting is not what the bound is about",
_resume.releases == 1 and _resume.released_at == 97.0
and not _resume.stale)
# ⚠️ AND "NO LONGER STANDING" IS `MOVE_W`, NOT SOME OTHER THRESHOLD IN THE SAME
# OBJECT. Found by mutating the SIBLING (habit 11): `_release()`'s test against
# `bound.move_w` swapped for `bound.unchanged_s` SURVIVED the whole file at
# +5 B, because every fixture above rests at exactly 0 W and 0 is below both
# 35 and 250. The band between them is where they separate, and a site can sit
# in it - a peak-guard floor, a shadow substitution or any claim that wins with
# a small non-zero number leaves a command there while the verdict stands.
_band = {}
for _w in (0.0, 100.0, 300.0):
_f = Freshness(BOUNDS["batt"])
for t in range(0, 40):
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
for t in range(40, 40 + 2 * int(BOUNDS["batt"].release_s)):
_f.submit(A_FROZEN_W, _w, float(t))
_band[_w] = _f.releases
print(f" releases after 34 s of a standing command of 0 / 100 / 300 W: "
f"{_band}")
check("⚠️ THE OBLIGATION TEST IS THE 250 W ONE, AND THE 100 W CASE IS WHAT "
"SAYS SO. A command of 100 W is below `move_w`, so the register is owed "
"nothing and the verdict is withdrawn exactly as at 0 W; a command of "
"300 W is above it, so the question is still standing and the verdict "
"is too. Every other fixture in this file rests at 0 W, where `move_w` "
"and `unchanged_s` cannot be told apart",
_band == {0.0: 1, 100.0: 1, 300.0: 0}
and BOUNDS["batt"].unchanged_s < 100.0 < BOUNDS["batt"].move_w < 300.0)
# ⚠️ AND THE ONE EQUIVALENT MUTANT THIS CARD FOUND, ARGUED RATHER THAN ROUNDED
# OFF. Swapping `_release()` and `_grade()` in `submit()` is ZERO BYTES and
# survived the whole battery. It is not a missing fixture and no fixture can
# close it: the two act on MUTUALLY EXCLUSIVE predicates - `_grade()` only when
# `|standing| >= move_w`, `_release()` only when `|standing| < move_w` - so at
# most one of them can do anything on any read. This check pins the disjointness
# rather than the order, which is the property that actually holds.
_disjoint = []
for _w in (0.0, 100.0, 249.0, 250.0, 251.0, 1300.0):
_a = Freshness(BOUNDS["batt"])
_b = Freshness(BOUNDS["batt"])
for t in range(0, 40):
for _f in (_a, _b):
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
for t in range(40, 80):
_a._release(_w, float(t))
_a._grade(_w, float(t))
_b._grade(_w, float(t))
_b._release(_w, float(t))
_disjoint.append((_w, _a.stale == _b.stale
and _a.releases == _b.releases
and _a.graded_from == _b.graded_from))
print(f" release-then-grade vs grade-then-release, by standing command: "
f"{_disjoint}")
check("⚠️ AN ARGUED EQUIVALENT MUTANT, NOT A SURVIVOR NOBODY LOOKED AT. "
"Swapping the two calls in `submit()` is zero bytes and changes nothing "
"on either side of `move_w`, including AT it - because the two are gated "
"on mutually exclusive tests and cannot both act on one read. The "
"docstring used to claim the order was load-bearing; it is not, and the "
"battery is what said so",
all(same for _w, same in _disjoint) and len(_disjoint) == 6)
# --- 10g. through the REAL read path, and the operator is told ---------------
_LOGREC = []
class _S57Handler(logging.Handler):
def emit(self, record):
_LOGREC.append(record)
logging.getLogger("goodwe").addHandler(_S57Handler())
logging.getLogger("goodwe").setLevel(logging.DEBUG)
async def _wired_release():
"""The whole chain at N=1: real `read_inputs`, real `Inverter`, real log."""
c, hass = one_site()
seen = []
for t in range(0, 40):
hass.numbers["sensor.batt_a"] = A_FROZEN_W
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
await c.read_inputs(now_mono=float(t))
seen.append((t, c.inverters[0].batt, c.inputs_ok))
for t in range(40, 40 + int(BOUNDS["batt"].release_s) + 2):
hass.numbers["sensor.batt_a"] = A_FROZEN_W # the register NEVER moves
c.inverters[0].last_write = 0.0 # the site's own 0 W
await c.read_inputs(now_mono=float(t))
seen.append((t, c.inverters[0].batt, c.inputs_ok))
return c, seen
_c57, _seen57 = run(_wired_release())
_trip57 = next(t for t, b, _ok in _seen57 if b is None)
_back57 = next(t for t, b, _ok in _seen57 if t > _trip57 and b is not None)
print(f" real read path: batt None from t={_trip57}, a number again at "
f"t={_back57}, register held {A_FROZEN_W:+.0f} throughout")
check("⚠️ THROUGH THE SHIPPED READ PATH, NOT THE POLICY OBJECT. "
"`Inverter.batt` goes None at the bound, `inputs_ok` goes False - and "
"17.0 s after the site's own 0 W it is a NUMBER again and `inputs_ok` is "
"True, with `sensor.batt_a` serving the identical -1200.0 on every one "
"of those reads",
_trip57 == 35 and _back57 == 57
and all(v == A_FROZEN_W for _e, v in _c57.hass.served
if _e == "sensor.batt_a")
and _seen57[_trip57][2] is False and _seen57[-1][2] is True
and _c57.inverters[0].batt == A_FROZEN_W)
check("...and the site stops holding inputs bad, which is what ends the "
"`inputs missing -> command 0 W` claim §11.1 rule 1 files",
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
_ev57 = [e for e in _c57.events if "STALE" in e]
_msgs57 = [r.getMessage() for r in _LOGREC]
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m]
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
"the event log and the logger, in order, naming the unit",
len(_ev57) == 2 and "STALE inverter-a" in _ev57[1]
and "STALE WITHDRAWN inverter-a" in _ev57[0]
and len(_rel57) == 1 and "inverter-a" in _rel57[0])
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
"happened: it says in its own words that nothing has been learned about "
"the register, and it prints the value the register is STILL holding",
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in _rel57[0]
and f"still holding {A_FROZEN_W:+.0f}" in _rel57[0])
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
# ⚠️ AN `in` TEST AGAINST A FORMATTED SENTENCE GRADES PRESENCE, NEVER
# ARRANGEMENT. Both of the sentences this card writes carry two numbers that
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
# escape clause, and 250 W and 17.0 s again in the release note - so each is
# asserted with its value embedded in its own literal position.
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
print(f" escape clause: ...{_stale57[0][-150:]}")
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
"PAIR. It used to end 'It clears itself the moment the register reports "
"a different value.' - which was FALSE, because the 0 W in the sentence "
"before it is what stops the register being asked. The new clause names "
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
"red",
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in _stale57[0])
check("...and the release note names 250 W in the POWER slot and 17 s in the "
"TIME slot, in that order, which is the reverse arrangement of the same "
"two numbers",
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
f"{BOUNDS['batt'].release_s:.0f} s" in _rel57[0])
check("...and the 35.0 s the register is re-graded over is named in the note "
"too, because it is what the operator is being told to wait out",
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
in _rel57[0])
check("⚠️ AND THE DIAGNOSIS HALF OF THE STALE LINE IS UNTOUCHED, WHICH IS "
"SAFETY-56'S. That card is rewriting 'is not following its own command' "
"- the live episode's register was following perfectly and the pack was "
"simply empty - and two cards editing one sentence is how one of them "
"silently loses. This check is here so that rewrite is a deliberate act",
"battery-power register is not following its own command" in _stale57[0])
check("...and a re-trip's reason names the TWO durations separately, each "
"beside its own value, because after a release the verdict's window and "
"the register's own unchanged time are different facts",
(lambda f: (
[f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
for t in range(0, 40)],
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
f.stale and f.releases == 1
and "unchanged for 92 s" in f.reason
and "the 35 s window this verdict rests on opened when the previous "
"one was withdrawn" in f.reason
and "graded 2 times" in f.reason)[-1])(
Freshness(BOUNDS["batt"])))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")