checks / unit (pull_request) Successful in 5m34s
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem - "a stale reading holds its last value and reads healthy" - and this builds the single policy. Both consumers the card names are gated by one change, and neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its window on a None measurement and `circulating_w` already returns None for a roster with a missing term. Neither could ever receive one. The gate is the piece that was missing BETWEEN the shipped read and the shipped checks. ⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a second and HA answers 200 with a number whether or not the publisher behind the entity is alive, so both members of the pair arrive identically for ever. test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and grades it failing - the age is 0.0 s at every instant of both runs, a threshold that can never fire, which is TEL-06's own defect one layer up. The half of the rule that survives is honoured absolutely: nothing here reads a Home Assistant timestamp, and the check asserts the module contains no `last_updated`. So the criterion became: a reading is stale when it has not moved through a change it was OBLIGED to follow. STALE <=> unchanged >= 35 s AND |standing| >= 250 W AND |standing - standing when the reading last changed| >= 250 W Latched until a different value arrives. `Inverter.batt` becomes None - never a held number - which is a missing input, which §11.1 rule 1 answers with 0 W. WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the committed 30-day capture of this house's own 1 W power entity (485,841 samples out of HA's recorder, where a row exists only on a CHANGE, so the gap between rows IS the unchanged duration every consumer saw): the longest legitimate unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips at 75 s. There is no admissible threshold. Every one of those long runs is under an UNMOVING load, which is what the travel term keys on. THE BOUND, derived at both ends and graded against the shipped config.yaml. Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0 ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period (run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75, unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED band and the check says so - the first draft claimed the band held one integer and went red on its own first run. MOVE_W is the LARGEST admissible value, unit_sign_disagree_w, because below it the consumer cannot speak at all. SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason. Its obligation term is energy through the pack, not command travel - a rested pack holds a percent for hours under a 0 W command - and K needs a usable capacity nobody has measured. That is TEL-08's; the record says so. ⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than hidden: a FROZEN register now stops the site where it used to read as a healthy measurement for ever. That is HW-01, which was a one-inverter site. A healthy install is unchanged, asserted over sixty reads of a dithering register under a reversing command. A false verdict fails toward 0 W behind the existing stale_input_s hold and clears itself on the register's next move. ⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different numbers passed in; inverter B does not physically exist (~2026-09-12). MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted before and after every revert. One survivor, and it is the finding - dropping the magnitude term left every check green because no fixture separated "the command travelled 250 W" from "the command ended up above 250 W". Closed, and recorded as MUTATION.md habit 17. A second process defect is habit 18: a mutation run that timed out between the edit and the revert left the module mutated on disk and nothing announced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
880 lines
44 KiB
Python
880 lines
44 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 glob
|
|
import gzip
|
|
import io
|
|
import json
|
|
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. 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",
|
|
all(v is None for v in _after_zero))
|
|
# ⚠️ 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)
|
|
|
|
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
|
|
"exactly the evidence the verdict was about. Not a timer, not a restart, "
|
|
"not the operator's start button",
|
|
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
|
|
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
|
|
# ⚠️ 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_change 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_change` 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",
|
|
_lat.held == A_FROZEN_W + 1.0 and not _lat.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" 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 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} {_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 - an infinite bound is how a "
|
|
"row silently becomes decorative",
|
|
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
|
|
for b in BOUNDS.values() if 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)
|
|
|
|
|
|
# ==============================================================================
|
|
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)
|
|
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|