- test_messages.py part 6: every dynamic part of a WARNING+ line is classified as data or as a built text, and every built text is produced by the real code and checked. This is the gap the 2d scan had: it counted `%s` as one word, so reasons built by loop_clamp_note() and friends were never seen. - Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept): OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak guard (codes now PEAK_GUARD*), the migration report, price feed, price history and gate reasons. Plurals via messages.times()/count(). - Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff "one every 15 minutes or every hour", "not final yet" dropped. - Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO, aiohttp access log only at log level debug, settings warnings once per start, one plain line per Home Assistant outage (hass.Outage). - INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE", "NOT_OBSERVED", "did NOT land", "AND". - Display bugs: WHY_WINNER covers every winner and safety reason (main.why_key(), part 7 finds them in the code); the meter row is amber "waiting for the first reading" inside its limit after a start; the missing-reading age is counted when the page asks. - STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section). No change to safety behaviour, alarm levels, notification ids, triggers, cadence or the banner cascade. One row colour changes (meter at start), justified in DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1991 lines
105 KiB
Python
1991 lines
105 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.
|
|
10. SAFETY-57's EXIT from the verdict, against the committed live capture.
|
|
11. SAFETY-56: THE SENTENCE THIS GATE CAN HONESTLY SAY. The conjunction is
|
|
satisfied by two disjoint worlds - a register that has stopped reporting,
|
|
and a source that cannot comply - and nothing here separates them. Part
|
|
11 builds both, shows the verdict is byte-identical across them, and
|
|
grades the three operator-facing sentences against that ambiguity.
|
|
|
|
⚠️ 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 import messages
|
|
from named_absence import first, given, nth, present
|
|
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 bool(_stale_read) 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",
|
|
len(_stale_read) > 35
|
|
and _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("stopped changing" 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 {given(_tgate):.2f} s of WALL clock, against the "
|
|
f"{_WD_S:.0f} s watchdog window ({_WD_S / given(_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 {given(_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 - or ABSENT."""
|
|
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 given(None, f"{key} not in config.yaml options")
|
|
|
|
|
|
_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 = nth(_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 {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_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(nth(_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):
|
|
if not present(t):
|
|
return repr(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 = first((t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM), "no trip")
|
|
_PARK = first((t for t, v in _sp if t > _TRIP and v == 0.0), "no 0 W after the trip")
|
|
_HUMAN = first((t for t, v in _sp if t > _PARK and v != 0.0), "no command after the park")
|
|
_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 = given(_first_clear_after(_rows_before, _TRIP), "no clear")
|
|
_clear_after = given(_first_clear_after(_rows_after, _TRIP), "no clear")
|
|
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",
|
|
present(_TRIP, _PARK, _HUMAN)
|
|
and 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 first(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 = first((t for t, b, _ok in _seen57 if b is None), "batt never None")
|
|
_back57 = first((t for t, b, _ok in _seen57 if t > _trip57 and b is not None),
|
|
"batt never back")
|
|
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 "is used again and rechecked" 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 nth(_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",
|
|
"used again" in nth(_rel57, 0) and "recovered" not in nth(_rel57, 0)
|
|
and f"reading {A_FROZEN_W:+.0f} W is used again and rechecked"
|
|
in nth(_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 "battery power reading stopped changing" in m]
|
|
print(f" escape clause: ...{nth(_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"or {BOUNDS['batt'].release_s:.0f} s after the command drops below "
|
|
f"{BOUNDS['batt'].move_w:.0f} W" in nth(_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"stayed below {BOUNDS['batt'].move_w:.0f} W for "
|
|
f"{BOUNDS['batt'].release_s:.0f} s" in nth(_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"rechecked over {BOUNDS['batt'].unchanged_s:.0f} s" in nth(_rel57, 0))
|
|
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
|
|
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
|
|
"register is not following its own command' to still be present, so "
|
|
"that removing it had to be a deliberate act rather than a side effect "
|
|
"of editing the escape clause beside it. SAFETY-56 is that act, and the "
|
|
"requirement is now the inverse: the accusation is GONE from this line "
|
|
"and part 11 grades what replaced it. The escape clause above is "
|
|
"SAFETY-57's and is untouched by it",
|
|
present(nth(_stale57, 0))
|
|
and "battery-power register is not following its own command"
|
|
not in _stale57[0]
|
|
and "stopped changing" 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 "checked 2 times over a 35 s window since the last stop was "
|
|
"withdrawn" in f.reason)[-1])(
|
|
Freshness(BOUNDS["batt"])))
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("11. SAFETY-56: the sentence this gate can honestly say")
|
|
# ⚠️ THE SUBJECT OF THIS PART IS A CLAIM, NOT A STRING, AND NOTHING IN IT
|
|
# CHANGES A DECISION. SAFETY-37's conjunction is correct and is untouched;
|
|
# what was wrong is what two sentences CONCLUDED from it. The conjunction is
|
|
# satisfied by two disjoint worlds -
|
|
#
|
|
# DEAD the register has stopped reporting reality. The pack really is
|
|
# doing what it was told; the published number does not say so.
|
|
# REFUSED the register is honest and the SOURCE cannot comply. The BMS is
|
|
# refusing at the pack floor, so the pack does nothing whatever the
|
|
# command asks, and an unchanged reading is the TRUTH.
|
|
#
|
|
# - and nothing this module is handed separates them. Three sentences named
|
|
# DEAD as fact: `freshness._grade()`'s "the reading is not following",
|
|
# `main.py`'s log line "battery-power register is not following its own
|
|
# command", and the NOT READY row's "<entity> is missing or not numeric". On
|
|
# the live 2026-09-21 episode all three were the wrong one of the two, and the
|
|
# third was the worst because it asserts a false fact about a NAMED entity: the
|
|
# operator opens Developer Tools, finds it working, and concludes the
|
|
# diagnostics are broken.
|
|
#
|
|
# So the property graded below is not "the new words are present". It is that
|
|
# the two worlds are REAL AND DIFFERENT (11a on the committed capture, 11b in a
|
|
# driven pair), that the gate CANNOT separate them (11b), and therefore that a
|
|
# clause asserting either one is false half the time - which is what makes 11c
|
|
# and 11d a claim rather than decoration. Habit 4: without 11a and 11b those
|
|
# two would be substring tests over a sentence this file also wrote.
|
|
|
|
# --- 11a. the old diagnosis was FALSE on the site it was printed on ----------
|
|
# ⚠️ OFF THE COMMITTED CAPTURE, NOT OFF A FIXTURE. Part 10a has already graded
|
|
# this file's bytes against the manifest and its decoder's sign convention
|
|
# against the frames, so the three series below are evidence rather than a
|
|
# story. Every boundary is derived in part 10b from the register's own longest
|
|
# hold; nothing here is typed.
|
|
_S56_CMDS_HELD = sorted({_D.held_at(_sp, t)
|
|
for t in range(int(_HOLD_FROM), int(_PARK) if present(_PARK) else 0)})
|
|
_S56_SOC_HELD = sorted({_D.held_at(_soc, t)
|
|
for t in range(int(_HOLD_FROM), int(_HUMAN) if present(_HUMAN) else 0)})
|
|
_S56_CHARGE = first(((t, v) for t, v in _sp if t > _PARK and v < 0.0), "no charge command")
|
|
_S56_CHARGE_T, _S56_CHARGE_W = _S56_CHARGE[0], _S56_CHARGE[1]
|
|
_S56_AFTER = _D.held_at(_bt, _REG_MOVES + 5.0)
|
|
print(f" during the 850 s hold: commands {_S56_CMDS_HELD} W, "
|
|
f"SoC {_S56_SOC_HELD} %, register {_HOLD_W:+.0f} W")
|
|
print(f" first CHARGE command {hms(_S56_CHARGE_T)} at "
|
|
f"{_S56_CHARGE_W:+.0f} W -> register moves {hms(_REG_MOVES)} "
|
|
f"({_REG_MOVES - _S56_CHARGE_T:.0f} s) to {_S56_AFTER:+.0f} W")
|
|
check("11a. ⚠️ THE OLD DIAGNOSIS WAS FALSE ON THE ONE SITE IT WAS EVER "
|
|
"PRINTED ON. Through the whole hold the site asked for DISCHARGE only "
|
|
"(+260 to +1060 W) out of a pack reading 11 %, and the register answered "
|
|
"-3 W. Then the first CHARGE command arrives and it moves within ONE "
|
|
"17.0 s obligation window. A register that answers a reversed command in "
|
|
"one window had not stopped reporting; what would not move was the PACK",
|
|
bool(_S56_CMDS_HELD) and all(v > 0.0 for v in _S56_CMDS_HELD)
|
|
and min(_S56_CMDS_HELD) == 260.0 and max(_S56_CMDS_HELD) == 1060.0
|
|
and _S56_SOC_HELD == [11.0] and _HOLD_W == -3.0
|
|
and _S56_CHARGE_W < 0.0
|
|
and 0.0 < _REG_MOVES - _S56_CHARGE_T <= F.BATT_OBLIGED_S
|
|
and _S56_AFTER != _HOLD_W)
|
|
check("...and the gate was RIGHT to fire anyway, which is why this card touches "
|
|
"no term of the conjunction. The three limits are still the derived ones "
|
|
"and the same capture still arms at 10:52:00 with the verdict standing: "
|
|
"a register genuinely stuck against a real command is exactly what "
|
|
"SAFETY-37 should catch, and a pack that cannot comply is a state the "
|
|
"site must stop in too. What was wrong was never the firing",
|
|
BOUNDS["batt"].unchanged_s == BATT_UNCHANGED_S
|
|
and BOUNDS["batt"].move_w == MOVE_W
|
|
and BOUNDS["batt"].release_s == F.BATT_OBLIGED_S
|
|
and hms(_TRIP) == "10:52:00" and _f_after.releases == 1
|
|
and first(r for r in _rows_after if r[0] == _TRIP)[1] is True)
|
|
|
|
# --- 11b. two worlds, one published stream, one verdict ----------------------
|
|
# ⚠️ GROUND TRUTH IS WRITTEN DOWN, NOT DERIVED FROM THE PUBLISHED STREAM
|
|
# (habit 2). A check that computes "what the pack was really doing" out of the
|
|
# number the register published is comparing a thing with itself, which is the
|
|
# defect MUTATION.md has recorded fifteen times.
|
|
_BATT_ENT = "sensor.batt_a"
|
|
_S56_N = 40
|
|
_S56_CMD = [800.0 if t < 1 else 1600.0 for t in range(_S56_N)]
|
|
_S56_PUB = [-3.0] * _S56_N # what the ENTITY publishes, both
|
|
_S56_TRUE_DEAD = list(_S56_CMD) # the pack obeyed; the register lied
|
|
_S56_TRUE_REFUSED = [-3.0] * _S56_N # the pack refused; the register told
|
|
|
|
|
|
async def _s56_world(soc_pct):
|
|
"""The shipped read path at N=1. The ground-truth stream above is never
|
|
handed to the controller - it is what the WORLD is, not an input."""
|
|
c, hass = one_site()
|
|
hass.numbers["sensor.soc_a"] = soc_pct
|
|
for t in range(_S56_N):
|
|
hass.numbers[_BATT_ENT] = _S56_PUB[t]
|
|
c.inverters[0].last_write = _S56_CMD[t]
|
|
await c.read_inputs(now_mono=float(t))
|
|
return c, hass
|
|
|
|
|
|
_S56_LOG0 = len(_LOGREC)
|
|
# ⚠️ THE TWO WORLDS ARE DRIVEN AT DIFFERENT SoC ON PURPOSE - 50 % against the
|
|
# 11 % the live pack sat at - so that "the sentence did not move" below is a
|
|
# measurement of this module's `soc` bound being STATED, NOT WIRED, and not an
|
|
# artefact of both runs being handed the same percentage. Wiring it is TEL-08's.
|
|
_c56_dead, _h56_dead = run(_s56_world(50.0))
|
|
_c56_ref, _h56_ref = run(_s56_world(11.0))
|
|
_pub_dead = [v for e, v in _h56_dead.served if e == _BATT_ENT]
|
|
_pub_ref = [v for e, v in _h56_ref.served if e == _BATT_ENT]
|
|
_S56_DIFFER = sum(1 for a, b in zip(_S56_TRUE_DEAD, _S56_TRUE_REFUSED) if a != b)
|
|
print(f" DEAD truth {_S56_TRUE_DEAD[:2]}... published {_pub_dead[:2]}...")
|
|
print(f" REFUSED truth {_S56_TRUE_REFUSED[:2]}... published {_pub_ref[:2]}...")
|
|
check(f"11b. PRECONDITION: the two worlds are REALLY TWO - their ground truth "
|
|
f"differs on {_S56_DIFFER} of {_S56_N} reads - and the PUBLISHED stream "
|
|
f"is bit-identical in both, so nothing downstream of {_BATT_ENT} can "
|
|
f"tell them apart. Without this the rest of part 11 is one world twice",
|
|
_S56_DIFFER == _S56_N and _pub_dead == _pub_ref == _S56_PUB
|
|
and len(_pub_dead) == _S56_N)
|
|
check("...and in REFUSED the register is RIGHT on every one of the 40 reads "
|
|
"(published == truth) while in DEAD it is wrong on all 40. That is the "
|
|
"fact the old sentence got backwards: it named the register in a world "
|
|
"where the register is the only thing telling the truth",
|
|
_S56_PUB == _S56_TRUE_REFUSED
|
|
and not any(p == t for p, t in zip(_S56_PUB, _S56_TRUE_DEAD)))
|
|
_r56_dead = _c56_dead.inverters[0].batt_fresh.reason
|
|
_r56_ref = _c56_ref.inverters[0].batt_fresh.reason
|
|
check("⚠️ AND THE GATE FIRES IDENTICALLY IN BOTH, WHICH IS THE WHOLE REASON "
|
|
"ITS SENTENCE MAY NOT NAME ONE OF THEM: same verdict, same MISSING "
|
|
"reading, same site stop - and the sentence is BYTE-IDENTICAL across two "
|
|
"worlds that share no ground truth at all",
|
|
_c56_dead.inverters[0].batt_fresh.stale is True
|
|
and _c56_ref.inverters[0].batt_fresh.stale is True
|
|
and _c56_dead.inverters[0].batt is None
|
|
and _c56_ref.inverters[0].batt is None
|
|
and _c56_dead.inputs_ok is False and _c56_ref.inputs_ok is False
|
|
and _r56_dead == _r56_ref and _r56_dead != "")
|
|
check("...and the two runs were driven at 50 % and 11 % SoC and the sentence "
|
|
"did not move one byte, because `BOUNDS['soc']` is STATED, NOT WIRED. A "
|
|
"message honest about its own ambiguity needs no new input, and adding "
|
|
"one here would make this sentence depend on a second measurement being "
|
|
"right - TEL-08's card, not this one",
|
|
_c56_dead.inverters[0].soc == 50.0 and _c56_ref.inverters[0].soc == 11.0
|
|
and _r56_dead == _r56_ref)
|
|
|
|
# --- 11c. both explanations, and no accusation, on every surface -------------
|
|
# The two explanations, each tagged with the world it is true OF. The emitted
|
|
# sentence is the same in both worlds (11b), so a sentence naming only one is
|
|
# false half the time - which is why BOTH are required, as an ORDERED PAIR
|
|
# (habit 14): two clauses in one sentence can always be transposed at zero
|
|
# bytes, and "the register may be unable to comply / the source may have
|
|
# stopped reporting reality" is a different and wrong claim.
|
|
_S56_EXPLANATIONS = [
|
|
("DEAD", "reading may have stopped"),
|
|
("REFUSED", "battery may be unable to follow the command"),
|
|
]
|
|
# ⚠️ WHAT THE THREE SENTENCES USED TO ASSERT. Each is a claim about the
|
|
# REGISTER or about the ENTITY, and 11a measured the register to be alive on
|
|
# the live episode while 11d measures the entity to be serving a float. They
|
|
# are listed with the file they came from so a reviewer can see the set is the
|
|
# whole set rather than the ones that were easy to find.
|
|
_S56_ACCUSATIONS = [
|
|
("app/freshness.py _grade()", "the reading is not following"),
|
|
("app/main.py _read_unit()", "register is not following its own command"),
|
|
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
|
|
]
|
|
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
|
|
if "battery power reading stopped changing" in r.getMessage()]
|
|
|
|
|
|
def _s56_row(c):
|
|
"""The battery-power readiness row, or a NAMED absence - never a raise.
|
|
|
|
⚠️ IT MUST NOT RAISE, AND THAT IS FCST-06'S RULE RATHER THAN TIDINESS. The
|
|
first draft of this part was `[... ][0]`, and the mutation that transposes
|
|
`label` and `entity` in the row - zero bytes - makes the row unfindable. The
|
|
run then died on an IndexError with every check after this line UNGRADED:
|
|
a red with no name attached, which reads like broken infrastructure and gets
|
|
retried rather than read. Measured: 0 named reds before, 2 after.
|
|
"""
|
|
hits = [r for r in c.checks() if r.get("id") == "sensor"
|
|
and r["text"].startswith(messages.LABEL_BATTERY)]
|
|
if len(hits) == 1:
|
|
return hits[0]
|
|
return {"ok": None, "warn": None,
|
|
"text": f"<NO SINGLE `battery power` ROW: {len(hits)} found>"}
|
|
|
|
|
|
_row56_ref = _s56_row(_c56_ref)
|
|
_S56_SURFACES = {
|
|
"reason (status page + action log)": _r56_ref,
|
|
"log line (SAFETY-37 ERROR)": _log56[-1] if _log56 else "",
|
|
"stale_note() (NOT READY row)": _c56_ref.inverters[0].stale_note(),
|
|
"checks() battery-power row": _row56_ref["text"],
|
|
}
|
|
for _n, _s in _S56_SURFACES.items():
|
|
print(f" {_n}: {_s[:96]}...")
|
|
check("PRECONDITION: the readiness row is FINDABLE BY ITS LABEL in both worlds "
|
|
"- exactly one `battery power` row each - so every row graded below is "
|
|
"that row, and a mutation that moves `label` out of the first slot is a "
|
|
"NAMED red here rather than an IndexError three checks later (FCST-06). "
|
|
f"REFUSED: {_row56_ref['text'][:44]!r}",
|
|
_row56_ref["ok"] is not None)
|
|
check("PRECONDITION: all four operator-facing surfaces were really produced by "
|
|
"this run and are non-empty, so the two quantifiers below are not over "
|
|
"an empty set - and the log line is the one the SHIPPED read path "
|
|
"emitted, not one this file formatted. Both worlds emitted one and the "
|
|
"two are the same string, which is 11b again on the log surface",
|
|
len(_log56) == 2 and _log56[0] == _log56[1]
|
|
and all(len(s) > 30 for s in _S56_SURFACES.values())
|
|
and _r56_ref in _log56[-1])
|
|
_idx56 = [_r56_ref.find(t) for _w, t in _S56_EXPLANATIONS]
|
|
check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
|
|
f"{_S56_EXPLANATIONS[0][1]!r} at {_idx56[0]} then "
|
|
f"{_S56_EXPLANATIONS[1][1]!r} at {_idx56[1]}. Pinned as an ordered pair "
|
|
f"because transposing 'register' and 'source' between the two clauses is "
|
|
f"a zero-byte edit that leaves both words in the sentence (habit 14)",
|
|
all(i >= 0 for i in _idx56) and _idx56 == sorted(_idx56)
|
|
and len(set(_idx56)) == len(_idx56))
|
|
check("...and the sentence says in its own words that it CANNOT choose, rather "
|
|
"than leaving two explanations side by side for a reader to rank - an "
|
|
"operator who reads a list picks the first item",
|
|
"this check cannot tell which" in _r56_ref)
|
|
# ⚠️ THE POSITIVE CONTROL FOR THE `not any(...)` BELOW, AND IT IS NOT OPTIONAL:
|
|
# a quantifier over an empty list is True, and so is one over three literals
|
|
# with a typo in each. These are the three sentences as 0.10.1 SHIPPED them,
|
|
# quoted from the release rather than rebuilt, and the same predicate is
|
|
# required to FIND an accusation in every one of them (habit 7: vary one thing
|
|
# from an ACCEPTED baseline).
|
|
_S56_SHIPPED_010_1 = {
|
|
"reason": "STALE_READING: -3 unchanged for 35 s while the standing command "
|
|
"moved 800 W to +1600 W - the reading is not following, so it is "
|
|
"reported as MISSING rather than as a measurement (limits: "
|
|
"unchanged over 35 s, command over 250 W and moved by over 250 W)",
|
|
"log": "SAFETY-37: inverter-a battery-power register is not following its "
|
|
"own command - STALE_READING: ... Treating it as MISSING, which "
|
|
"commands 0 W.",
|
|
"row": f"battery power: {_BATT_ENT} is missing or not numeric",
|
|
}
|
|
check(f"POSITIVE CONTROL: the very same predicate, run against the three "
|
|
f"sentences as 0.10.1 SHIPPED them, finds an accusation in EVERY one - "
|
|
f"and every one of the {len(_S56_ACCUSATIONS)} literals matches at least "
|
|
f"one of them, so neither an empty list nor a mistyped literal can make "
|
|
f"the check below pass by vacuity",
|
|
len(_S56_ACCUSATIONS) == 3 and len(_S56_EXPLANATIONS) == 2
|
|
and all(any(t in old for _w, t in _S56_ACCUSATIONS)
|
|
for old in _S56_SHIPPED_010_1.values())
|
|
and all(any(t in old for old in _S56_SHIPPED_010_1.values())
|
|
for _w, t in _S56_ACCUSATIONS))
|
|
check("⚠️ AND NOT ONE OF THE THREE ACCUSATIONS SURVIVES, ON ANY OF THE FOUR "
|
|
"SURFACES: " + "; ".join(f"{w} -> {t!r}" for w, t in _S56_ACCUSATIONS)
|
|
+ ". This is the check that goes red if any of the three sentences is "
|
|
"reverted, and it is stated over the SURFACES rather than over the "
|
|
"source files because two of the three are assembled from two modules",
|
|
not any(t in s for _w, t in _S56_ACCUSATIONS
|
|
for s in _S56_SURFACES.values()))
|
|
|
|
# --- 11d. the NOT READY row that named a working entity ----------------------
|
|
async def _s56_absent():
|
|
"""The other half of the row's ambiguity: the entity really is gone."""
|
|
c, hass = one_site()
|
|
for t in range(3):
|
|
hass.numbers[_BATT_ENT] = None # Home Assistant has no state
|
|
c.inverters[0].last_write = 0.0
|
|
await c.read_inputs(now_mono=float(t))
|
|
return c, hass
|
|
|
|
|
|
_c56_abs, _h56_abs = run(_s56_absent())
|
|
_last_ref = [v for e, v in _h56_ref.served if e == _BATT_ENT][-1]
|
|
_last_abs = [v for e, v in _h56_abs.served if e == _BATT_ENT][-1]
|
|
_row56_abs = _s56_row(_c56_abs)
|
|
print(f" WITHHELD: {_BATT_ENT} served {_last_ref!r}, stale="
|
|
f"{_c56_ref.inverters[0].batt_fresh.stale} ABSENT: served "
|
|
f"{_last_abs!r}, stale={_c56_abs.inverters[0].batt_fresh.stale}")
|
|
check("11d. PRECONDITION: the row's two causes are both REACHED. In REFUSED "
|
|
f"{_BATT_ENT} served a real float on the last read and the gate is "
|
|
f"holding the value back; in ABSENT Home Assistant served None and the "
|
|
f"gate is not armed at all. Two different causes, one `inv.batt is None`",
|
|
_last_ref == -3.0 and _last_abs is None
|
|
and _c56_ref.inverters[0].batt_fresh.stale is True
|
|
and _c56_abs.inverters[0].batt_fresh.stale is False
|
|
and _c56_ref.inverters[0].batt is None
|
|
and _c56_abs.inverters[0].batt is None)
|
|
check("⚠️ AND THE VERDICT IS UNCHANGED BY THIS CARD - both rows are still "
|
|
"ok=False, warn=False, i.e. still NOT READY. The defect was never the "
|
|
"pass/fail, it was the row asserting a fact about a named entity that "
|
|
"was at that moment present, numeric and updating",
|
|
_row56_ref["ok"] is False and _row56_ref["warn"] is False
|
|
and _row56_abs["ok"] is False and _row56_abs["warn"] is False
|
|
and _row56_ref["text"] == _row56_abs["text"])
|
|
# UX-08: the row names the setting by its label, never the entity id
|
|
# (STYLE.md), so there is no second interpolation left to transpose.
|
|
check("...and what it says instead is the observation plus BOTH causes, under "
|
|
"its own label, and the check to make before replacing the sensor - with "
|
|
f"no entity id ({_BATT_ENT}) on the page row",
|
|
_row56_ref["text"].startswith(f"{messages.LABEL_BATTERY}: no usable "
|
|
f"reading")
|
|
and "may be missing" in _row56_ref["text"]
|
|
and "held back as too old" in _row56_ref["text"]
|
|
and "Developer Tools before replacing it" in _row56_ref["text"]
|
|
and _BATT_ENT not in _row56_ref["text"])
|
|
check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which is "
|
|
"why the row does not need to ask `batt_fresh.stale` itself: `checks()` "
|
|
"carries a STALE_READING row in REFUSED and NONE in ABSENT, so 'is that "
|
|
"row below?' answers the question this one declines to answer. A row "
|
|
"that pointed at a surface present in both states would be worse than "
|
|
"the accusation it replaced",
|
|
any("stopped changing" in r["text"] for r in _c56_ref.checks())
|
|
and not any("stopped changing" in r["text"] for r in _c56_abs.checks()))
|
|
check("...and the row's wording is NOT shared with the log line's, which is a "
|
|
"design choice rather than an omission: the two sentences have no clause "
|
|
"in common longer than the word 'entity', because one explains a "
|
|
"BEHAVIOUR to a log reader and the other tells a page reader whether to "
|
|
"trust a NAME. A shared helper would have to drop whichever half the "
|
|
"other caller does not need",
|
|
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
|
|
and _S56_EXPLANATIONS[1][1] not in _row56_ref["text"]
|
|
and "no usable reading" not in _r56_ref)
|
|
|
|
# --- 11e. what part 11 does NOT grade, with its shape (habit 13) -------------
|
|
# * It does not show the live episode was a BMS refusal beyond doubt. 11a
|
|
# measures that the register answered a reversed command inside one
|
|
# obligation window, which excludes a register that had simply stopped; it
|
|
# does not exclude one that was intermittently wrong. That is the point: the
|
|
# sentence no longer claims to know either.
|
|
# * It does not grade ENGLISH. A wording that named both explanations and then
|
|
# said something useless about them would pass 11c.
|
|
# * The `no entity configured` branch beside the rewritten row is untouched and
|
|
# ungraded here - it has no second explanation, because a blank entity really
|
|
# is the only thing it can be.
|
|
# * The grid-power and battery-SoC rows go through the same rewritten line and
|
|
# have no freshness gate today, so for them the second cause is a hypothesis
|
|
# about this controller rather than a live possibility. That is weaker than
|
|
# the battery-power case and is the price of not branching per row; a gate on
|
|
# either of those signals (TEL-08 would be one) makes it exact.
|
|
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|