checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where "capping how far the loop can jump ahead of the inverter" was true because the rail itself bounded the excursion. The live install went to max_w 5000 and the constant did not move, so the loop crosses its whole output range in 10 s against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five 2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved. REPRODUCED FIRST, and one half of it is not reproducible at all. On plantsim.Plant and on an independent in-repo model: the setpoint stepping a full 1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from export into import. The -5154 W figure is NOT reproducible in either model, because both clamp the plant at its rating - so the card's AC "battery no longer exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met. THE DELIVERABLE IS THE RELATIONSHIP. SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9 slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as integrator_max_w = 0 already means follow max_w. It is the shipped default: 222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. validate.py rule 10 refuses an explicit value faster than that, graded against sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a second inverter moves the bound with no operator action. VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the refusal names the rail, the crossing time, the measurement and what to do. Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating all still start. GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51 export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250) and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W rail in under three ticks whatever the slew is. Two control-law constants moving in one release on a running house makes the next measurement uninterpretable. ⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant did - both stated in the record rather than left to be discovered. Nothing reached the inverter. The live install still runs the interim slew_w 400 applied by hand on 2026-09-05, which rule 10 accepts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1271 lines
65 KiB
Python
1271 lines
65 KiB
Python
"""Runnable check for SAFETY-18, the frozen-but-fresh meter detector.
|
|
|
|
python3 test_freeze.py
|
|
|
|
Six parts. Parts 2 and 3 are the acceptance argument; parts 5 and 6 exist
|
|
because TEST-06 found three mutations that survived parts 1-4 green - the
|
|
evaluation POSITION inside cycle(), the NOT COVERED status string, and the
|
|
shipped `meter_freeze_s` itself:
|
|
|
|
1. the predicate's boundaries and the edge-trigger, table-driven;
|
|
2. SILENT ON REAL CONVERGED DATA. `scenarios/p1-age-2026-08-26.jsonl.gz` is
|
|
40 minutes of this house's live meter at the operating point, committed
|
|
here from the infra repo's `sim/scenarios/`. It is replayed through the
|
|
REAL `control.compute()` at the real cycle rule, and the detector must not
|
|
make a sound. This is the acceptance criterion TEL-05's objection is
|
|
about, and it is verified rather than argued;
|
|
3. FIRES ON A GENUINELY FROZEN METER, over real HTTP, through the REAL
|
|
`HomeWizardLocalSource` - the transport is what computes `unchanged_s`, so
|
|
a check that calls the predicate directly would not be testing the thing
|
|
that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra
|
|
repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub
|
|
otherwise. Both are exercised; the run says which one it used.
|
|
|
|
4. THE WIRING: the real `Controller.cycle()`, asserted on the service call
|
|
that leaves the process;
|
|
5. WHERE IN `cycle()` the detector is evaluated - by consequence, not by
|
|
source order. It runs after `write_setpoint()`, and that is what makes two
|
|
compositions safe (see the section for both);
|
|
6. WHAT THE STATUS PAGE SAYS about an uncovered transport, and the shipped
|
|
threshold read back out of `config.yaml`.
|
|
|
|
⚠️ Part 3 runs in WALL CLOCK - `unchanged_s` is `time.monotonic()` and the
|
|
transport really polls - so it drives the detector at `hold_s=10`, not the
|
|
shipped 300. The THRESHOLD is config; the MECHANISM is what is under test, and
|
|
the mechanism cannot be hurried. Part 2 uses the shipped 300 because that data
|
|
is replayed rather than lived.
|
|
|
|
⚠️ Part 3 also runs its NEGATIVE twin, and that is the load-bearing half: the
|
|
same railed command against a meter that is still MOVING, at the same 10 s
|
|
threshold. If that one alarms, the detector is a `unchanged_s` threshold with
|
|
extra steps and the whole reason for the conjunction is gone.
|
|
|
|
------------------------------------------------------------------------------
|
|
THE RIG RUN THIS STANDS IN FOR
|
|
|
|
Part 3 is the portable version of a run done on the real rig - the `goodwe-sim`
|
|
compose project on 192.168.2.147, under `sim/riglock.py`, with the driver staged
|
|
into `goodwe-sim-plant` so it polls that container's own hwsim while plantsim
|
|
drives it. Both phases passed there. To repeat it:
|
|
|
|
DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status
|
|
|
|
⚠️ WITHOUT `DOCKER_HOST` SET, ON A WINDOWS HOST, `riglock status` REPORTS
|
|
`rig: UNKNOWN` - it shells out to the local `docker` CLI, which resolves to a
|
|
Docker Desktop npipe that is not running. That is INDISTINGUISHABLE from a
|
|
genuinely absent rig, and it is not one: the rig lives on the homelab box and was
|
|
up the whole time. `UNKNOWN` is the tool declining to observe, not an
|
|
observation. Do not read it as "no rig" (this cost this ticket a wrong caveat).
|
|
|
|
The rig run also produced a result part 3's stub cannot: under +8000 W of house
|
|
load the meter's POWER FIGURE never moved - 10130.0 W for 26 consecutive polls -
|
|
and only the kWh registers kept `unchanged_s` at zero. See the note in
|
|
freeze.py; it is why the fingerprint's register terms are the negative case
|
|
rather than a nicety.
|
|
|
|
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
|
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
|
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
|
edit reverted inside one second is invisible to CPython's staleness test. That
|
|
is TEST-15, and it has already turned one live mutation into a false survivor.
|
|
Read MUTATION.md before you report a survivor.
|
|
"""
|
|
|
|
import asyncio
|
|
import gzip
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import aiohttp
|
|
|
|
from app.control import Tuning, compute
|
|
from app.freeze import (
|
|
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen,
|
|
rail_ceiling_w, rail_unreachable, rail_w,
|
|
)
|
|
from app.main import Controller
|
|
from app.p1 import HomeWizardLocalSource, P1Ingest
|
|
|
|
fails = []
|
|
T0 = datetime(2026, 8, 26, 7, 0, 0, tzinfo=timezone.utc)
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
# ⚠️ NOT config.yaml's shipped max_w. This is the LIVE INSTALL's own setting -
|
|
# the schema maximum, 5000 - which this file uses on purpose wherever the
|
|
# scenario under test is "a device smaller than the configured max_w" (freeze.py
|
|
# describes exactly this install). Named LIVE_INSTALL_MAX_W, not the shorter
|
|
# LIVE_MAX_W it used to be called, so it cannot be misread as "the current
|
|
# shipped default" - see SHIPPED_MAX_W below for that number, and the pin two
|
|
# sections down for what stops the two from drifting apart unnoticed (TEST-10).
|
|
LIVE_INSTALL_MAX_W = 5000.0
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# THE SHIPPED DEFAULTS, READ FROM config.yaml (TEST-06 / TEST-10 / AC3)
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ `meter_freeze_s: 300` used to appear in this file only as the literal 300,
|
|
# retyped six times, so `300 -> 3600` in config.yaml left every check here
|
|
# green. Everything below is now driven off the SHIPPED value, and the value
|
|
# itself is pinned in test_validate.py the way MAINT-04 pins the other defaults.
|
|
#
|
|
# Regex, not PyYAML: the add-on image installs py3-aiohttp/py3-paho-mqtt only,
|
|
# and a check that skips on ImportError is not a check.
|
|
_CFG_TXT = (HERE / "config.yaml").read_text(encoding="utf-8")
|
|
|
|
|
|
def _cfg_default(key):
|
|
"""The single ` key: <number>` default line in config.yaml's options block."""
|
|
found = re.findall(rf"^ {key}: (-?\d+(?:\.\d+)?)\s*$", _CFG_TXT, re.M)
|
|
return float(found[0]) if len(found) == 1 else None
|
|
|
|
|
|
# THE KEYS THIS FILE PINS - one place, so the next key added to config.yaml's
|
|
# options block can be checked against it rather than discovered unread later
|
|
# (TEST-10 / AC3). Deliberately not every key: max_w, deadband_w and
|
|
# meter_freeze_s are the ones this file's arithmetic actually reasons about
|
|
# (the mutual-exclusion argument in section 2b needs all three together).
|
|
# Everything else in config.yaml's options block is out of scope here - see
|
|
# test_validate.py for the general "no default silently drifts" pin, and do
|
|
# NOT add a maintenance_* key here: CFG-03 is moving that default between
|
|
# config.yaml, main.py and maintenance.py concurrently with this ticket.
|
|
PINNED_KEYS = ("meter_freeze_s", "deadband_w", "max_w")
|
|
SHIPPED_HOLD_S, SHIPPED_DEADBAND_W, SHIPPED_MAX_W = (
|
|
_cfg_default(key) for key in PINNED_KEYS)
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def tuning(max_w=LIVE_INSTALL_MAX_W, slew_w=0):
|
|
"""The shipped defaults from config.yaml, at the live install's max_w.
|
|
|
|
⚠️ `slew_w` IS 0 - "FOLLOW THE RAIL" - AND IS A PARAMETER (SAFETY-39). 0 is
|
|
what config.yaml ships and resolves to `SLEW_RAIL_FRAC * max_w`, so it moves
|
|
with `max_w` instead of being a number typed beside it. It has to be
|
|
overridable because one case below picks an ABSURDLY SMALL rail on purpose,
|
|
and at that rail a rail-following slew is 2 W per cycle - which would make
|
|
the slew, rather than the thing under test, the reason nothing fires.
|
|
"""
|
|
return Tuning(gain=0.6, max_w=max_w, slew_w=slew_w, deadband_w=15,
|
|
target_grid_w=-10, step_w=10, saturation_w=500,
|
|
saturation_cycles=3)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 1. the predicate and the edge
|
|
# --------------------------------------------------------------------------- #
|
|
print("predicate")
|
|
cfg = FreezeWatchdogConfig(hold_s=300)
|
|
|
|
check("both terms true -> frozen", looks_frozen(300, 5000, 5000, cfg))
|
|
check("unchanged long, command NOT railed -> silent (TEL-05's case)",
|
|
not looks_frozen(3600, -40, 5000, cfg))
|
|
check("command railed, meter still moving -> silent (a real 8 kW load)",
|
|
not looks_frozen(4, -5000, 5000, cfg))
|
|
check("neither -> silent", not looks_frozen(4, -40, 5000, cfg))
|
|
check("one second under the hold is still silent",
|
|
not looks_frozen(299.9, 5000, 5000, cfg))
|
|
check("just under the rail fraction is still silent",
|
|
not looks_frozen(3600, 5000 * RAIL_FRAC - 1, 5000, cfg))
|
|
check("at the rail fraction exactly, it fires",
|
|
looks_frozen(3600, 5000 * RAIL_FRAC, 5000, cfg))
|
|
check("the rail scales with max_w, not with a fixed watt figure",
|
|
looks_frozen(3600, 2000, 2000, cfg) and not looks_frozen(3600, 2000, 5000, cfg))
|
|
check("a charge rail counts too - the runaway can be either sign",
|
|
looks_frozen(3600, -5000, 5000, cfg))
|
|
check("no transport signal (unchanged_s None) -> silent, never assumed frozen",
|
|
not looks_frozen(None, 5000, 5000, cfg))
|
|
check("no command yet -> silent", not looks_frozen(3600, None, 5000, cfg))
|
|
check("rail_w tracks RAIL_FRAC", rail_w(5000) == 5000 * RAIL_FRAC)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 1c. SAFETY-20 - the rail is measured against what the WIRE can carry
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ EVERY CHECK IN THIS SECTION WAS RED BEFORE THE FIX AND IS THE FIX'S ONLY
|
|
# EVIDENCE. The shipped rail was `RAIL_FRAC * max_w` and the emitted command is
|
|
# clipped to the SETPOINT DEVICE's range as well (clamp.py's second stage, and
|
|
# the arbiter's `device` P_SAFETY claim), so any rating at or below
|
|
# `0.95 * max_w` made term 2 unsatisfiable by construction while the status page
|
|
# went on printing `armed (... over 4750 W)`.
|
|
#
|
|
# THIS IS ALSO THE CHECK THAT HAS TO GO RED IF ANYONE REVERTS THE RAIL TO BARE
|
|
# `max_w` (AC3). Verified by doing it - reinstating
|
|
# `return abs(float(max_w)) * RAIL_FRAC` in `rail_w()` fails the six checks
|
|
# below plus three in part 6, and nothing else in the suite.
|
|
print("SAFETY-20: the rail against the device rating")
|
|
|
|
# The ticket's own numbers: a 5 kW ceiling on a 4.7 kW inverter.
|
|
DEV = dict(dev_min=-4700.0, dev_max=4700.0)
|
|
|
|
check("the rail is the DEVICE's rail, not max_w's, when the device is smaller",
|
|
rail_w(LIVE_INSTALL_MAX_W, **DEV) == 4700.0 * RAIL_FRAC
|
|
and rail_w(LIVE_INSTALL_MAX_W, **DEV) != LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
check("a command pinned at the device rating with a meter frozen for an hour "
|
|
"FIRES - this is the ticket, and it returned False before the fix",
|
|
looks_frozen(3600, 4700, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
|
check("...and the identical call without the rating is still False, so the "
|
|
"check above is measuring the rating and not something else",
|
|
not looks_frozen(3600, 4700, LIVE_INSTALL_MAX_W, cfg))
|
|
check("the charge rail moves with it too - the runaway can be either sign",
|
|
looks_frozen(3600, -4700, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
|
check("just under the DEVICE rail is still silent, so the fix widened term 2 "
|
|
"by exactly the rating and not into a bare unchanged_s threshold",
|
|
not looks_frozen(3600, 4700 * RAIL_FRAC - 1, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
|
check("a device BIGGER than max_w cannot raise the rail - max_w is still a "
|
|
"ceiling the operator set and this is a min(), not a swap",
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_min=-9000.0, dev_max=9000.0)
|
|
== LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
check("an unread rating (ESPHome not up) leaves the old behaviour exactly - "
|
|
"no device clamp is applied on that path either, so max_w IS the ceiling",
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_min=None, dev_max=None) == LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
check("one bound alone still lowers it - hass.limits() reads min and max "
|
|
"independently and either can come back None on its own",
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_max=4000.0) == 4000.0 * RAIL_FRAC)
|
|
check("a non-finite rating is IGNORED, never read as a 0 W ceiling",
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_min=float("nan"), dev_max=float("inf"))
|
|
== LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
check("the ceiling helper says what it measured",
|
|
rail_ceiling_w(LIVE_INSTALL_MAX_W, **DEV) == 4700.0)
|
|
|
|
# The degenerate end. A ceiling of 0 W does not disarm term 2, it makes it TRUE
|
|
# OF EVERY COMMAND - including a parked 0 W one - which decays the conjunction
|
|
# into the bare `unchanged_s > N` threshold TEL-05 refused for false-tripping at
|
|
# this controller's own operating point.
|
|
check("a device that can command nothing makes the rail UNREACHABLE, with a "
|
|
"reason for the status page",
|
|
bool(rail_unreachable(LIVE_INSTALL_MAX_W, dev_min=0.0, dev_max=0.0)))
|
|
check("...and a reachable rail says so with an empty string, or the row above "
|
|
"would be permanent",
|
|
rail_unreachable(LIVE_INSTALL_MAX_W, **DEV) == ""
|
|
and rail_unreachable(LIVE_INSTALL_MAX_W) == "")
|
|
check("...and the predicate is SILENT there rather than degenerately true - "
|
|
"0 W would otherwise satisfy `abs(command) >= 0`",
|
|
not looks_frozen(3600, 0.0, LIVE_INSTALL_MAX_W, cfg, dev_min=0.0, dev_max=0.0)
|
|
and not looks_frozen(3600, 5000, LIVE_INSTALL_MAX_W, cfg, dev_min=0.0,
|
|
dev_max=0.0))
|
|
|
|
# The alarm text is the third surface that carried the untrue number - it
|
|
# outlives the page, in the log and in the HA notification.
|
|
_wd20 = FreezeWatchdog(cfg)
|
|
_wd20.evaluate(3600, 4700, LIVE_INSTALL_MAX_W, T0, **DEV)
|
|
print(f" at max_w {LIVE_INSTALL_MAX_W:.0f} on a +-4700 W device: "
|
|
f"{_wd20.reason}")
|
|
check("the alarm reason quotes the rail it actually used and NOT the max_w one "
|
|
"the command could never have reached",
|
|
f"command over {4700 * RAIL_FRAC:.0f} W" in _wd20.reason
|
|
and f"{LIVE_INSTALL_MAX_W * RAIL_FRAC:.0f} W" not in _wd20.reason)
|
|
check("...and it says the device is what capped it, so the operator is not "
|
|
"left wondering why the guard is armed somewhere other than max_w",
|
|
"rating" in _wd20.reason and "max_w" in _wd20.reason)
|
|
|
|
print("the edge")
|
|
wd = FreezeWatchdog(cfg)
|
|
edges = [wd.evaluate(u, c, 5000, T0 + timedelta(seconds=i))
|
|
for i, (u, c) in enumerate(
|
|
[(10, 5000), (300, 5000), (305, 5000), (310, 5000),
|
|
(2, 5000), (300, 5000)])]
|
|
check("rises exactly once per episode", edges == [False, True, False, False,
|
|
False, True])
|
|
check("two episodes counted", wd.episodes == 2)
|
|
check("clearing the condition clears the alarm state",
|
|
not FreezeWatchdog(cfg).evaluate(2, 5000, 5000, T0))
|
|
check("the reason names both terms",
|
|
"unchanged over 300" in wd.reason and "command over 4750" in wd.reason)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 1b. the SHIPPED threshold, and the two properties it has to have (AC3)
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ This is the number the whole ticket is an argument about, and it was pinned
|
|
# nowhere: `meter_freeze_s: 300 -> 3600` used to leave this file and
|
|
# test_validate.py both green. test_validate.py pins the literal in MAINT-04's
|
|
# manner; here it is pinned by its CONSEQUENCES, so a schema-legal value that
|
|
# gives up the argument goes red even though it is a legal value.
|
|
print("the shipped meter_freeze_s (AC3)")
|
|
check(f"config.yaml's numeric defaults were read (freeze {SHIPPED_HOLD_S}, "
|
|
f"deadband {SHIPPED_DEADBAND_W}, max_w {SHIPPED_MAX_W})",
|
|
None not in (SHIPPED_HOLD_S, SHIPPED_DEADBAND_W, SHIPPED_MAX_W))
|
|
check("the code default and the shipped default are the same number - "
|
|
"FreezeWatchdogConfig() is a default, config.yaml is the ship",
|
|
FreezeWatchdogConfig().hold_s == SHIPPED_HOLD_S)
|
|
check("this file's `tuning()` really is config.yaml's deadband, so the "
|
|
"mutual-exclusion arithmetic below is about the shipped law",
|
|
tuning().deadband_w == SHIPPED_DEADBAND_W)
|
|
# TEST-10: max_w had no such pin - `max_w: 2000 -> 4500` in config.yaml left
|
|
# every check in this file green, the same gap TEST-06 closed for
|
|
# meter_freeze_s one key over. `Tuning`'s own bare default (control.py) is
|
|
# 2000.0 because that IS the shipped default; comparing the two means a
|
|
# config.yaml edit that is not matched in control.py goes red here, the same
|
|
# way FreezeWatchdogConfig().hold_s catches a drifted meter_freeze_s above.
|
|
# This is deliberately NOT a check against LIVE_INSTALL_MAX_W (5000) - that
|
|
# constant is the live install's own override and is supposed to differ from
|
|
# the shipped default; see its definition for why.
|
|
check("the code default and the shipped default agree on max_w too - "
|
|
"Tuning() is a default, config.yaml is the ship",
|
|
Tuning().max_w == SHIPPED_MAX_W)
|
|
|
|
# The upper bound is money: config.yaml's own comment says 300 s is "still
|
|
# inside the 900 s quarter that bills the capacity tariff". A hold that outlives
|
|
# the quarter notifies after the peak is already billed.
|
|
QUARTER_S = 900.0
|
|
check(f"the alarm still lands inside the {QUARTER_S:.0f} s billing quarter "
|
|
f"({SHIPPED_HOLD_S:.0f} s hold)", SHIPPED_HOLD_S < QUARTER_S)
|
|
# The lower bound is the conjunction itself - see the derivation below, which
|
|
# now runs off the shipped numbers rather than off two retyped literals.
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 2. silent on 40 minutes of real converged behaviour
|
|
# --------------------------------------------------------------------------- #
|
|
print("converged replay - scenarios/p1-age-2026-08-26.jsonl.gz")
|
|
rows = [json.loads(line) for line in
|
|
gzip.open(HERE / "scenarios" / "p1-age-2026-08-26.jsonl.gz", "rt")]
|
|
check("the capture loaded", len(rows) > 1000)
|
|
|
|
# ⚠️ The capture records the POWER figure only. The shipped fingerprint also
|
|
# covers both kWh registers, so a real `unchanged_s` on this data can only be
|
|
# SHORTER than what is reconstructed here - this replay is the pessimistic
|
|
# version of the converged case, not a flattering one.
|
|
def replay(hold_s, max_w, cycle_floor_s=15.0, slew_w=0):
|
|
"""Drive the real control law and the real detector over the capture."""
|
|
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
|
|
t = tuning(max_w, slew_w)
|
|
target, i_w, sat = 0.0, 0.0, 0
|
|
prev_pwr, changed_at = None, rows[0]["t"]
|
|
last_cycle = None
|
|
peak_cmd, peak_unchanged, fired_at = 0.0, 0.0, None
|
|
for r in rows:
|
|
now, pwr = r["t"], float(r["pwr"])
|
|
if prev_pwr is None or pwr != prev_pwr:
|
|
changed_at = now
|
|
arrival = (prev_pwr is None or pwr != prev_pwr)
|
|
prev_pwr = pwr
|
|
# main.run_control()'s rule: a cycle per changed reading, or the floor.
|
|
if arrival or last_cycle is None or now - last_cycle >= cycle_floor_s:
|
|
last_cycle = now
|
|
d = compute(prev_w=target, grid_w=pwr, actual_w=target, tuning=t,
|
|
sat_count=sat, i_w=i_w)
|
|
target, i_w, sat = d.target_w, d.i_w, d.sat_count
|
|
# ⚠️ The detector is evaluated on EVERY poll, not only on cycles, and
|
|
# that is deliberately harsher than the shipped wiring (which evaluates
|
|
# it inside cycle()). On an arrival-triggered cycle `unchanged_s` is 0
|
|
# by construction, so grading only there would be grading the detector
|
|
# on the one sample it cannot possibly fire on.
|
|
unchanged = now - changed_at
|
|
peak_cmd = max(peak_cmd, abs(target))
|
|
peak_unchanged = max(peak_unchanged, unchanged)
|
|
if wd.evaluate(unchanged, target, max_w,
|
|
T0 + timedelta(seconds=now - rows[0]["t"])) \
|
|
and fired_at is None:
|
|
fired_at = now - rows[0]["t"]
|
|
return wd, peak_cmd, peak_unchanged, fired_at
|
|
|
|
|
|
for mw in (SHIPPED_MAX_W, LIVE_INSTALL_MAX_W):
|
|
wd_r, peak_cmd, peak_unchanged, fired = replay(SHIPPED_HOLD_S, mw)
|
|
print(f" max_w {mw:.0f}: worst |command| {peak_cmd:.0f} W (rail "
|
|
f"{rail_w(mw):.0f} W), worst unchanged {peak_unchanged:.1f} s "
|
|
f"(hold {SHIPPED_HOLD_S:.0f} s), episodes {wd_r.episodes}")
|
|
check(f"max_w {mw:.0f}: never alarms on the converged capture", fired is None)
|
|
check(f"max_w {mw:.0f}: and the rail term had margin to spare",
|
|
peak_cmd < rail_w(mw))
|
|
|
|
# The margin on the OTHER term, as numbers rather than as trust. Both figures
|
|
# are quoted in config.yaml and freeze.py, so they are re-measured every run and
|
|
# a capture swapped underneath them cannot leave the prose saying the old thing.
|
|
_, _, worst_unchanged, _ = replay(SHIPPED_HOLD_S, LIVE_INSTALL_MAX_W)
|
|
gaps, last, prev = [], rows[0]["t"], None
|
|
for r in rows:
|
|
if prev is None or r["pwr"] != prev:
|
|
gaps.append(r["t"] - last)
|
|
last = r["t"]
|
|
prev = r["pwr"]
|
|
print(f" longest unchanged_s any sample would observe: {worst_unchanged:.1f} s")
|
|
print(f" longest interval serving no distinct reading: {max(gaps):.1f} s "
|
|
f"(spans an 11.4 s hole in the capture's own polling)")
|
|
check("worst observed unchanged_s is the 8 s the docs quote",
|
|
7.5 < worst_unchanged < 8.5)
|
|
check("longest distinct-reading interval is the 15.4 s the docs quote",
|
|
15.0 < max(gaps) < 16.0)
|
|
check(f"the shipped {SHIPPED_HOLD_S:.0f} s is at least 15x the worst of the two",
|
|
SHIPPED_HOLD_S >= 15 * max(gaps))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 2b. TEL-05's case, reconstructed, because the capture cannot contain it
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ BE HONEST ABOUT WHAT THE CAPTURE ABOVE PROVES AND WHAT IT DOES NOT. On this
|
|
# house's meter the power figure jitters at 1 W, so the worst `unchanged_s` any
|
|
# sample sees is 8 s - which means the capture alone does NOT refute a bare
|
|
# `unchanged_s > 300` threshold, and reporting it as though it did would be the
|
|
# confident explanation nobody checked. The case that refutes the bare threshold
|
|
# is the one p1.py describes and no capture of THIS meter contains: a converged
|
|
# meter whose power figure simply repeats, where the 1 Wh export register needs
|
|
# 360 s to advance at -10 W and the fingerprint therefore stands still.
|
|
#
|
|
# So it is constructed, from the operating point the capture measures (mean grid
|
|
# -14.2 W) rather than invented: one hour of a perfectly healthy converged meter
|
|
# that happens not to move.
|
|
print("TEL-05's low-power case - a converged meter that legitimately repeats")
|
|
tel05 = FreezeWatchdog(FreezeWatchdogConfig(hold_s=SHIPPED_HOLD_S))
|
|
t5 = tuning(LIVE_INSTALL_MAX_W)
|
|
tgt, iw, sc = 0.0, 0.0, 0
|
|
bare_would_fire = False
|
|
tel05_cmd = 0.0
|
|
# Long enough that the REJECTED bare threshold has to get its chance even if
|
|
# someone raises meter_freeze_s - the mutation must be caught by the pins above,
|
|
# not by this case going red for an incidental reason.
|
|
TEL05_S = max(3600, int(SHIPPED_HOLD_S) + 300)
|
|
for k in range(TEL05_S):
|
|
if k % 15 == 0: # cycle_floor_s, the only trigger left
|
|
d = compute(prev_w=tgt, grid_w=-14.2, actual_w=tgt, tuning=t5,
|
|
sat_count=sc, i_w=iw)
|
|
tgt, iw, sc = d.target_w, d.i_w, d.sat_count
|
|
tel05_cmd = max(tel05_cmd, abs(tgt))
|
|
# The REJECTED design, written out so the case can be shown to be real.
|
|
bare_would_fire = bare_would_fire or float(k) >= SHIPPED_HOLD_S
|
|
tel05.evaluate(float(k), tgt, LIVE_INSTALL_MAX_W, T0 + timedelta(seconds=k))
|
|
print(f" {TEL05_S / 60:.0f} min at -14.2 W: worst |command| {tel05_cmd:.0f} W, "
|
|
f"unchanged_s reaches {TEL05_S - 1} s, episodes {tel05.episodes}")
|
|
check("the bare unchanged_s threshold TEL-05 rejected WOULD fire here",
|
|
bare_would_fire)
|
|
check("the conjunction does not - this is what the rail term buys",
|
|
tel05.episodes == 0)
|
|
check("...and the command stayed nowhere near the rail",
|
|
tel05_cmd < rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
# The structural version of the same point, re-derived rather than asserted in
|
|
# prose: the slowest register a HEALTHY meter can have while still being seen as
|
|
# unchanged for hold_s is 3600/hold_s watts, and at the shipped defaults that
|
|
# figure is inside the deadband, so the command there is parked at 0 W. If
|
|
# someone lowers meter_freeze_s or narrows the deadband, this is the check that
|
|
# should stop them.
|
|
DEADBAND_W = SHIPPED_DEADBAND_W # config.yaml's, not a retyped 15
|
|
slowest_healthy_w = 3600.0 / SHIPPED_HOLD_S
|
|
print(f" at the shipped {SHIPPED_HOLD_S:.0f} s hold, the slowest healthy "
|
|
f"meter that still looks unchanged is {slowest_healthy_w:.1f} W, against "
|
|
f"a {DEADBAND_W:.0f} W deadband")
|
|
check("a healthy meter slow enough to look unchanged is inside the deadband",
|
|
slowest_healthy_w < DEADBAND_W)
|
|
_park = compute(prev_w=0.0, grid_w=-slowest_healthy_w, actual_w=0.0,
|
|
tuning=tuning(LIVE_INSTALL_MAX_W), sat_count=0, i_w=0.0)
|
|
check("...and the shipped law really does park the command there",
|
|
_park.target_w == 0.0)
|
|
|
|
# ⚠️ THE PROOF THAT THE REPLAY IS CAPABLE OF FIRING AT ALL. Without this, "the
|
|
# converged capture is silent" is satisfied by a detector that is wired to
|
|
# nothing. Drop the hold to 1 s and the rail to nothing, and the SAME replay
|
|
# must alarm - so the silence above is the data's doing, not the harness's.
|
|
# ⚠️ AND THE SLEW IS PINNED FOR THIS CASE ONLY, WHICH IS THE OTHER HALF OF
|
|
# MAKING BOTH TERMS REACHABLE (SAFETY-39). The rail here is 20 W, chosen absurdly
|
|
# small so the rail term cannot be what holds the alarm off. At the shipped
|
|
# `slew_w: 0` the limit follows that rail too - `SLEW_RAIL_FRAC * 20` is 2.2 W
|
|
# per cycle - so the command could not reach 19 W inside the capture and the
|
|
# replay would go silent for a reason that has nothing to do with the detector.
|
|
# That is exactly the vacuous pass this case exists to rule out, arriving
|
|
# through the case itself, so the slew is written out here and nowhere else.
|
|
wd_hot, _, _, fired_hot = replay(1.0, 20.0, slew_w=20.0)
|
|
check("...and the same replay DOES alarm once both terms are made reachable",
|
|
fired_hot is not None and wd_hot.episodes > 0)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 3. a genuinely frozen meter, over real HTTP, through the real transport
|
|
# --------------------------------------------------------------------------- #
|
|
def find_hwsim():
|
|
"""The rig's own fake meter, if the infra repo is reachable.
|
|
|
|
⚠️ `HERE.parents[1]` only exists when this package sits at least two levels
|
|
below the filesystem root - it raised IndexError and took the WHOLE suite
|
|
down on a checkout at `C:/goodwe_controller`, which is a failure of the
|
|
lookup, not of the thing being tested. Guarded, so a shallow checkout falls
|
|
through to the stub like any other missing sibling repo.
|
|
"""
|
|
cands = []
|
|
if os.environ.get("GOODWE_SIM"):
|
|
cands.append(Path(os.environ["GOODWE_SIM"]) / "hwsim.py")
|
|
if len(HERE.parents) > 1:
|
|
cands.append(HERE.parents[1] / "goodwe" / "sim" / "hwsim.py")
|
|
for c in cands:
|
|
if c.exists():
|
|
return c
|
|
return None
|
|
|
|
|
|
STUB = r'''
|
|
import http.server, json, sys, threading, time
|
|
FROZEN = {"active_power_w": 700.0, "active_power_l1_w": 700.0,
|
|
"total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123}
|
|
moving = len(sys.argv) > 2 and sys.argv[2] == "moving"
|
|
t0 = time.monotonic()
|
|
class H(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
d = dict(FROZEN)
|
|
if moving:
|
|
# A REAL railed condition: a big steady import, so the power figure
|
|
# jitters and the 1 Wh registers advance the way physics makes them.
|
|
el = time.monotonic() - t0
|
|
d["active_power_w"] = 7000.0 + (int(el * 10) % 7)
|
|
d["active_power_l1_w"] = d["active_power_w"]
|
|
d["total_power_import_kwh"] = round(1234.567 + 7000 * el / 3.6e6, 3)
|
|
raw = json.dumps(d).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
def log_message(self, *a): pass
|
|
http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()
|
|
'''
|
|
|
|
HWSIM_DRIVER = r'''
|
|
import sys, threading, time
|
|
sys.path.insert(0, sys.argv[3])
|
|
import hwsim
|
|
hwsim.serve(int(sys.argv[1]))
|
|
mode = sys.argv[2]
|
|
hwsim.set_grid(7000.0)
|
|
if mode == "freeze":
|
|
hwsim.fault = "freeze" # exactly `hwsim.py --fault freeze`
|
|
while True:
|
|
# The plant keeps moving underneath either way. Under `freeze` hwsim's own
|
|
# set_grid() returns early, which is the whole point of that fault.
|
|
hwsim.set_grid(7000.0 + (int(time.monotonic() * 10) % 7))
|
|
time.sleep(0.2)
|
|
'''
|
|
|
|
|
|
async def live_run(port, mode, hold_s=10.0, seconds=16.0, max_w=LIVE_INSTALL_MAX_W):
|
|
"""Poll a real HTTP meter through the real transport and drive the detector."""
|
|
ingest = P1Ingest(phases=1, max_age_s=30)
|
|
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
|
|
t = tuning(max_w)
|
|
target, i_w, sat = 0.0, 0.0, 0
|
|
fired_at, polls, worst_unchanged, worst_cmd = None, 0, 0.0, 0.0
|
|
async with aiohttp.ClientSession() as session:
|
|
src = HomeWizardLocalSource(session, ingest, "127.0.0.1", port=port,
|
|
poll_s=1.0)
|
|
start = time.monotonic()
|
|
while time.monotonic() - start < seconds:
|
|
if await src.poll_once():
|
|
polls += 1
|
|
grid = ingest.net_w
|
|
d = compute(prev_w=target, grid_w=grid, actual_w=target,
|
|
tuning=t, sat_count=sat, i_w=i_w)
|
|
target, i_w, sat = d.target_w, d.i_w, d.sat_count
|
|
worst_unchanged = max(worst_unchanged, src.unchanged_s or 0.0)
|
|
worst_cmd = max(worst_cmd, abs(target))
|
|
if wd.evaluate(src.unchanged_s, target, max_w,
|
|
datetime.now(timezone.utc)) and fired_at is None:
|
|
fired_at = time.monotonic() - start
|
|
await asyncio.sleep(1.0)
|
|
return fired_at, polls, worst_unchanged, worst_cmd
|
|
|
|
|
|
def with_server(argv, port, fn):
|
|
proc = subprocess.Popen([sys.executable, "-c"] + argv,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
try:
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
import socket
|
|
with socket.create_connection(("127.0.0.1", port), 0.3):
|
|
break
|
|
except OSError:
|
|
time.sleep(0.1)
|
|
else:
|
|
raise RuntimeError("the fake meter never came up")
|
|
return fn()
|
|
finally:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
|
|
hw = find_hwsim()
|
|
# ⚠️ WHICH TRANSPORT BACKED PART 3 IS PART OF THE RESULT, and it is repeated in
|
|
# the summary at the end of the run. The two are not equivalent: pointed at the
|
|
# real `sim/hwsim.py` the rig path failed 1 run in 3 (a 16 s window against a
|
|
# 10 s hold, when hwsim.serve() is slow to start), while the stub is tuned never
|
|
# to fail. A green log that does not say which one ran is a green log that can
|
|
# have quietly substituted the weaker stand-in - the same shape as an assertion
|
|
# that cannot fail, from the other end.
|
|
PART3_TRANSPORT = f"the rig hwsim at {hw}" if hw else "the local stub"
|
|
print(f"frozen meter over HTTP - using {PART3_TRANSPORT}")
|
|
if hw:
|
|
def argv_for(mode):
|
|
return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode,
|
|
str(hw.parent)]
|
|
else:
|
|
def argv_for(mode):
|
|
return [STUB, "8791" if mode == "freeze" else "8792",
|
|
"frozen" if mode == "freeze" else "moving"]
|
|
|
|
fired, polls, unch, cmd = with_server(
|
|
argv_for("freeze"), 8791,
|
|
lambda: asyncio.run(live_run(8791, "freeze")))
|
|
print(f" freeze: {polls} polls, worst unchanged {unch:.1f} s, "
|
|
f"worst |command| {cmd:.0f} W, alarm at "
|
|
f"{'never' if fired is None else f'{fired:.1f} s'}")
|
|
check("the transport really polled a live server", polls >= 10)
|
|
check("a frozen meter raises the alarm", fired is not None)
|
|
check("...and only after the hold, not on the first railed poll",
|
|
fired is None or fired >= 10.0)
|
|
|
|
fired2, polls2, unch2, cmd2 = with_server(
|
|
argv_for("moving"), 8792,
|
|
lambda: asyncio.run(live_run(8792, "moving")))
|
|
print(f" moving: {polls2} polls, worst unchanged {unch2:.1f} s, "
|
|
f"worst |command| {cmd2:.0f} W, alarm at "
|
|
f"{'never' if fired2 is None else f'{fired2:.1f} s'}")
|
|
check("the moving meter also drove the command to the rail", cmd2 >= rail_w(LIVE_INSTALL_MAX_W))
|
|
check("a REAL railed condition against a live meter stays silent", fired2 is None)
|
|
check("...because the meter kept moving", unch2 < 10.0)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 4. the wiring, because a correct detector nobody calls is not a detector
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ EVERYTHING ABOVE PASSES WITH `app/freeze.py` IMPORTED BY NOTHING. That is
|
|
# the shape of defect this project keeps finding: the mechanism is right, the
|
|
# suite is green, and the shipped path never reaches it. So the real
|
|
# `Controller.cycle()` is driven here, against the real arbiter and the real
|
|
# control law, with a stand-in transport that reports `unchanged_s` the way
|
|
# HomeWizardLocalSource does - and the assertion is on the SERVICE CALL that
|
|
# leaves the process, not on the watchdog's own flag.
|
|
print("the wiring - Controller.cycle() actually raises the notification")
|
|
|
|
|
|
class _Store:
|
|
data: dict = {}
|
|
|
|
def set(self, *a):
|
|
pass
|
|
|
|
def get_time(self, *a):
|
|
return None
|
|
|
|
|
|
class _Pub:
|
|
enabled = False
|
|
|
|
def publish(self, *a, **k):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _Hass:
|
|
def __init__(self, grid):
|
|
self.grid = grid
|
|
self.calls = []
|
|
self.writes = []
|
|
# The plant tracks the command instantly, as in test_loop.py. Without
|
|
# it the saturation freeze pins the command short of the rail and the
|
|
# runaway under test never happens - which would have made this a check
|
|
# that passes for the wrong reason.
|
|
self.batt = 0.0
|
|
# ⚠️ The dead-poll-task case (AC1's second half): every input reads
|
|
# None, exactly as it does when the transport task has died while the
|
|
# controller keeps cycling.
|
|
self.dead = False
|
|
|
|
async def limits(self, entity):
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
async def number(self, entity, invert=False):
|
|
if self.dead:
|
|
return None
|
|
return {"sensor.grid": self.grid, "sensor.soc": 50.0,
|
|
"sensor.batt": self.batt}.get(entity)
|
|
|
|
async def set_number(self, entity, value):
|
|
self.writes.append(float(value))
|
|
self.batt = float(value)
|
|
return True
|
|
|
|
async def call(self, domain, service, data):
|
|
self.calls.append((domain, service, data))
|
|
return True
|
|
|
|
|
|
class _Source:
|
|
"""Only the attribute the controller reads. Duck-typed like the real one."""
|
|
|
|
def __init__(self, unchanged_s):
|
|
self.unchanged_s = unchanged_s
|
|
|
|
|
|
# ⚠️ meter_freeze_s is deliberately NOT in here. A controller built with the key
|
|
# absent must land on the shipped number by itself, and that is asserted below -
|
|
# supplying it here would test this dict instead of the shipped default.
|
|
STALE_INPUT_S = 15.0
|
|
|
|
|
|
def _opts(**over):
|
|
o = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off", "max_w": LIVE_INSTALL_MAX_W, "gain": 0.6,
|
|
"slew_w": 0, "deadband_w": SHIPPED_DEADBAND_W,
|
|
"target_grid_w": -10, "step_w": 10, "heartbeat_s": 10,
|
|
"stale_input_s": STALE_INPUT_S}
|
|
o.update(over)
|
|
return o
|
|
|
|
|
|
# ⚠️ TWO SURFACES, AND EVERY ASSERTION BELOW SAYS WHICH ONE IT IS ABOUT.
|
|
# The first cut of this helper selected by SERVICE, which measured "any guard's
|
|
# notification" while every assertion built on it was named for THIS guard's.
|
|
# That was true only because `check_meter_frozen()` was the sole
|
|
# `persistent_notification.create` in `app/`; SAFETY-03 (#12) adds a second one
|
|
# (`goodwe_output_clamp`) raised by scenarios that rail the command on purpose,
|
|
# and eight assertions here went red against it - AC1's core check included.
|
|
#
|
|
# Blanket id-keying is NOT the repair. Three of those eight are claims about the
|
|
# WHOLE notification surface, and narrowing them to this guard's id would have
|
|
# silently shrunk a claim rather than fixed a conflation - which is the defect
|
|
# class this card exists to close. So both surfaces are available by name, and
|
|
# each assertion picks one deliberately.
|
|
FREEZE_NOTE_ID = "goodwe_meter_frozen"
|
|
|
|
|
|
def all_notes_of(hass):
|
|
"""EVERY HA persistent notification raised this run, whoever raised it."""
|
|
return [d for dom, svc, d in hass.calls
|
|
if (dom, svc) == ("persistent_notification", "create")]
|
|
|
|
|
|
def _is_freeze_note(d):
|
|
"""Is this notification the freeze detector's?
|
|
|
|
⚠️ THE ID **OR** THE TITLE, and the `or` is measured rather than tidy.
|
|
Keyed on the id alone, renaming the id empties every list in this file and
|
|
SIX assertions collapse together - so the one check whose subject IS the id
|
|
could no longer fail on its own, in the file about assertions that cannot
|
|
fail. Recognising the guard by what it says as well leaves the rename
|
|
exactly one red: the id's own check. Measured both ways:
|
|
|
|
selector vs #12's app/ id renamed
|
|
by service (the first cut) 8 red 1 red
|
|
by id alone 3 red 6 red
|
|
by id or title (this) 3 red 1 red
|
|
|
|
The union cannot re-admit another guard's alarm unless that guard also
|
|
calls itself frozen, which would be a confusable alarm on its own account.
|
|
"""
|
|
return (d.get("notification_id") == FREEZE_NOTE_ID
|
|
or "frozen" in str(d.get("title", "")).lower())
|
|
|
|
|
|
def notes_of(hass):
|
|
"""Only THIS guard's - see _is_freeze_note()."""
|
|
return [d for d in all_notes_of(hass) if _is_freeze_note(d)]
|
|
|
|
|
|
def notes_by_text_of(hass):
|
|
"""This guard's, recognised by what it SAYS and never by its id.
|
|
|
|
Exists for the two assertions whose subject IS the id: selected by the id,
|
|
both would be true by construction.
|
|
"""
|
|
return [d for d in all_notes_of(hass)
|
|
if "frozen" in str(d.get("title", "")).lower()]
|
|
|
|
|
|
def wired(unchanged_s, grid, cycles=60, dev=(-5000.0, 5000.0), **over):
|
|
"""Returns (hass, controller, trace).
|
|
|
|
`trace` is one row per cycle - (command after the cycle, the watchdog's
|
|
level, notifications raised so far) - which is what AC1 needs: the
|
|
QUESTION IS NOT WHETHER THE ALARM HAPPENS BUT ON WHICH CYCLE.
|
|
|
|
⚠️ `dev` IS THE SETPOINT DEVICE'S OWN REPORTED RANGE, and it is a parameter
|
|
rather than a constant because SAFETY-20 is entirely about installs where it
|
|
is NOT the default +-5000. It defaults to the live install's rating, which
|
|
equals its `max_w` - which is exactly why that install never saw the defect.
|
|
"""
|
|
async def go():
|
|
hass = _Hass(grid)
|
|
c = Controller(_opts(**over), hass, _Store(), _Pub(),
|
|
stop=asyncio.Event())
|
|
c.auto = True
|
|
c.dev_min, c.dev_max = dev
|
|
c.p1_source = _Source(unchanged_s)
|
|
trace = []
|
|
for _ in range(cycles):
|
|
await c.read_inputs()
|
|
await c.cycle()
|
|
trace.append((c.target, c.freeze_wd.active, len(notes_of(hass))))
|
|
return hass, c, trace
|
|
return asyncio.run(go())
|
|
|
|
|
|
# ⚠️ SAFETY-03 (#12) arrived at the same diagnosis independently and added a
|
|
# `frozen_notes()` keyed on the id. `notes_of()` above subsumes it - same id,
|
|
# plus the title fallback that keeps the id's own check independently
|
|
# falsifiable, plus `all_notes_of()` for the claims that are about the whole
|
|
# surface rather than about this guard.
|
|
hass_f, ctl_f, trace_f = wired(3600.0, 4000.0)
|
|
notes = notes_of(hass_f) # THIS guard's
|
|
all_notes = all_notes_of(hass_f) # the whole surface
|
|
# The status row lives behind `p1_enabled`, which is what a homewizard_local
|
|
# install has. Flipped here rather than plumbing a whole P1 transport in - the
|
|
# row's own content is what is under test.
|
|
ctl_f.p1_enabled = True
|
|
rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
|
|
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
|
|
f"{len(notes)} notification(s), {len(rows)} red status row(s)")
|
|
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03
|
|
# ships `goodwe_output_clamp`, and this scenario - a runaway loop winding into
|
|
# the rail - raises it legitimately: the loop asks for more than the rating and
|
|
# is clamped. So the answer to "is this scenario still meant to raise exactly
|
|
# one alarm" is NO, it raises exactly two, and BOTH ARE NAMED here rather than
|
|
# the claim being narrowed until it went green again. A third guard has to come
|
|
# back to this line, which is the whole point of keeping one assertion in the
|
|
# file that accounts for the controller's entire alarm output.
|
|
_ids = sorted(d.get("notification_id") for d in all_notes)
|
|
print(f" the whole notification surface this run: {_ids}")
|
|
check("the whole notification surface carried exactly two alarms this run - "
|
|
"SAFETY-18's and SAFETY-03's, both named",
|
|
_ids == ["goodwe_meter_frozen", "goodwe_output_clamp"])
|
|
# THIS GUARD'S, keyed on the id.
|
|
check("...and the freeze detector is the one that raised it", len(notes) == 1)
|
|
# ⚠️ Selected by TEXT, not by id - see notes_by_text_of(). One of the two
|
|
# assertions whose subject IS the id (the other is the stability check in part
|
|
# 5), so it must not be selected by the thing it is checking, and it has to be
|
|
# able to fail while everything else stays green.
|
|
check("...with a fixed notification_id so repeats replace, not stack",
|
|
[d.get("notification_id") for d in notes_by_text_of(hass_f)]
|
|
== [FREEZE_NOTE_ID])
|
|
check("...and it says the controller is still running",
|
|
bool(notes) and "STILL RUNNING" in notes[0]["message"])
|
|
check("the status page carries a failed row saying so",
|
|
len(rows) == 1 and not rows[0]["ok"] and not rows[0]["warn"])
|
|
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
|
|
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
|
|
# and change this expectation deliberately, not discover it in the field.
|
|
check("and it does NOT command 0 W - this cut is an alarm only",
|
|
abs(ctl_f.target) >= rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
# ⚠️ THE THREE SCENARIOS THAT SAFETY-03 FORCED A DECISION ON, decided one at a
|
|
# time and not reflexively narrowed to green. Measured on the merged tree:
|
|
#
|
|
# scenario surface ids freeze
|
|
# frozen meter, railed command (above) 2 frozen + clamp 1
|
|
# TEL-05 converged, -14.2 W 0 - 0
|
|
# moving meter, a real 8 kW load 1 clamp 0
|
|
# uncovered transport, no unchanged_s 1 clamp 0
|
|
#
|
|
# CONVERGED stays SURFACE-WIDE. At the operating point this controller exists to
|
|
# hold, the correct behaviour is that the whole add-on has nothing to say - not
|
|
# merely that the freeze guard has nothing to say. Nothing about SAFETY-03
|
|
# weakens that claim, and it still holds, so narrowing it would give away a real
|
|
# property for nothing.
|
|
#
|
|
# The other two NARROW to notes_of(), and the reason is the same in both: the
|
|
# clamp alarm there is CORRECT and is SAFETY-03's subject, not this file's. A
|
|
# genuine 8 kW load really does drive the loop past the rating; an uncovered
|
|
# transport really does not stop it doing so. Asserting silence across the whole
|
|
# surface would make this file fail whenever a different guard correctly does
|
|
# its job - a claim about somebody else's behaviour wearing this file's name.
|
|
# What each still owns is the load-bearing half: THE FREEZE DETECTOR SAID
|
|
# NOTHING. The surface is printed rather than asserted, so a reader can still
|
|
# see what else fired without this file grading it.
|
|
hass_n, ctl_n, _ = wired(3600.0, -14.2)
|
|
check("wired up, TEL-05's converged case leaves the WHOLE SURFACE silent - at "
|
|
"the operating point the add-on should have nothing at all to say",
|
|
not all_notes_of(hass_n))
|
|
|
|
hass_m, ctl_m, _ = wired(2.0, 8000.0)
|
|
print(f" real 8 kW load, moving meter: surface "
|
|
f"{sorted(d.get('notification_id') for d in all_notes_of(hass_m))}, "
|
|
f"freeze {len(notes_of(hass_m))}")
|
|
check("wired up, a real railed load against a moving meter raises nothing FROM "
|
|
"THIS GUARD (the clamp alarm there is SAFETY-03's and is correct)",
|
|
not notes_of(hass_m) and abs(ctl_m.target) >= rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
hass_u = wired(None, 4000.0)[0]
|
|
print(f" uncovered transport: surface "
|
|
f"{sorted(d.get('notification_id') for d in all_notes_of(hass_u))}, "
|
|
f"freeze {len(notes_of(hass_u))}")
|
|
check("an uncovered transport (no unchanged_s) raises nothing FROM THIS GUARD, "
|
|
"which is the honest report of a detector that cannot see",
|
|
not notes_of(hass_u))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 5. WHERE in cycle() the detector is evaluated (TEST-06 / AC1)
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ WHERE THE BOUNDARY ACTUALLY IS - AND IT IS NOT write_setpoint().
|
|
# The first version of this comment said the detector runs "after
|
|
# write_setpoint()" and grades "the command that has just gone on the wire".
|
|
# TEST-06's reviewer disproved both halves by mutation:
|
|
#
|
|
# N3 move check_meter_frozen() to immediately BEFORE write_setpoint()
|
|
# -> green, and CORRECTLY green
|
|
#
|
|
# because `self.target = resolution.target_w` is assigned two lines above the
|
|
# write, `write_setpoint()` clamps only a local `value`, and it can skip the
|
|
# write entirely (`if not (changed or force): return`). So what the detector
|
|
# grades is `self.target` - the arbiter's resolved intent, PRE device clamp and
|
|
# possibly never emitted - and the invariant these checks pin is:
|
|
#
|
|
# check_meter_frozen() runs AFTER `self.target = resolution.target_w`
|
|
#
|
|
# That distinction is not pedantry. A check more robust than its own
|
|
# explanation is how the next person moves the call ABOVE the assignment
|
|
# believing they had preserved the stated rule.
|
|
#
|
|
# Both properties below are asserted by an OBSERVABLE CONSEQUENCE rather than by
|
|
# source order, so semantic-preserving refactors stay green (measured by the
|
|
# reviewer: renaming the method, and calling it from a helper that itself runs
|
|
# last, both survive - the suite drives `await c.cycle()` and never names the
|
|
# method):
|
|
#
|
|
# (a) BOTH TERMS MUST DESCRIBE THE SAME MOMENT. The command it grades has to
|
|
# be the one THIS cycle resolved, judged against how long the meter that
|
|
# chose it has stood still. Evaluated above the assignment it grades the
|
|
# PREVIOUS cycle's command against THIS cycle's meter - a pairing that was
|
|
# never true of any moment - and the alarm arrives a whole cycle late.
|
|
# (b) THE DEAD-POLL-TASK COMPOSITION. When the poll task dies the inputs go
|
|
# stale, the arbiter resolves 0 W, and the detector - evaluated after that
|
|
# resolution lands in self.target - correctly sees an unrailed command and
|
|
# CLEARS. Evaluated above the assignment it would still be looking at the
|
|
# railed command from before the failsafe, and would keep the alarm
|
|
# standing over a controller that is already commanding 0 W.
|
|
print("WHERE the detector is evaluated in cycle() - the ordering AC1 protects")
|
|
|
|
_railed = [i for i, (t, _, _) in enumerate(trace_f)
|
|
if abs(t) >= rail_w(LIVE_INSTALL_MAX_W)]
|
|
_alarmed = [i for i, (_, _, n) in enumerate(trace_f) if n >= 1]
|
|
_first_railed = _railed[0] if _railed else None
|
|
_first_alarm = _alarmed[0] if _alarmed else None
|
|
print(f" ramp to the rail: first railed command on cycle {_first_railed}, "
|
|
f"first notification on cycle {_first_alarm}, over {len(trace_f)} cycles")
|
|
# Without this the equality below could be satisfied on cycle 0, where "the same
|
|
# cycle" and "one cycle later" are indistinguishable.
|
|
check("the command takes several cycles to reach the rail, so 'same cycle' and "
|
|
"'one cycle later' are different observations",
|
|
_first_railed is not None and _first_railed >= 1
|
|
and len(trace_f) > _first_railed + 1)
|
|
check("the alarm lands on the SAME cycle the command reaches the rail - "
|
|
"evaluated after `self.target = resolution.target_w` it grades the "
|
|
"command THIS cycle resolved; evaluated above that assignment it grades "
|
|
"the previous one and arrives a cycle late",
|
|
_first_alarm is not None and _first_alarm == _first_railed)
|
|
|
|
|
|
def dead_poll(after_cycles=20, dead_cycles=4, revived_cycles=20):
|
|
"""Alarm on a railed command, then kill the poll task and keep cycling.
|
|
|
|
The real thing: the transport task dies, every input reads None, and after
|
|
`stale_input_s` the arbiter's P_SAFETY stop resolves 0 W. `inputs_bad_since`
|
|
is backdated rather than slept through - the clock is wall clock, and the
|
|
mechanism rather than the wait is what is under test.
|
|
"""
|
|
async def go():
|
|
hass = _Hass(4000.0)
|
|
c = Controller(_opts(), hass, _Store(), _Pub(), stop=asyncio.Event())
|
|
c.auto = True
|
|
c.dev_min, c.dev_max = -5000.0, 5000.0
|
|
c.p1_source = _Source(3600.0)
|
|
for _ in range(after_cycles):
|
|
await c.read_inputs()
|
|
await c.cycle()
|
|
before = (c.target, c.freeze_wd.active, len(notes_of(hass)))
|
|
hass.dead = True
|
|
trace = []
|
|
for _ in range(dead_cycles):
|
|
await c.read_inputs()
|
|
if c.inputs_bad_since is not None:
|
|
c.inputs_bad_since = (datetime.now(timezone.utc)
|
|
- timedelta(seconds=STALE_INPUT_S + 1))
|
|
await c.cycle()
|
|
trace.append((c.target, c.freeze_wd.active, len(notes_of(hass))))
|
|
# ...and then the transport comes back, against a meter that is still
|
|
# frozen. The loop re-rails and this is a NEW episode - the alarm is
|
|
# allowed, indeed required, to speak again.
|
|
hass.dead = False
|
|
revived = []
|
|
for _ in range(revived_cycles):
|
|
await c.read_inputs()
|
|
await c.cycle()
|
|
revived.append((c.target, c.freeze_wd.active, len(notes_of(hass))))
|
|
# ⚠️ by TEXT: "the second episode REPLACED the first" is a claim about
|
|
# the id, so the list it is checked against must not be selected by the
|
|
# id - that would make it true by construction.
|
|
return before, trace, revived, notes_by_text_of(hass), c
|
|
return asyncio.run(go())
|
|
|
|
|
|
_before, _dead, _revived, _freeze_notes, _ctl_d = dead_poll()
|
|
_zeroed = [i for i, (t, _, _) in enumerate(_dead) if t == 0.0]
|
|
print(f" poll task killed: alarming at {_before[0]:+.0f} W "
|
|
f"(active={_before[1]}, {_before[2]} notification(s)) -> "
|
|
+ ", ".join(f"cycle {i}: {t:+.0f} W active={a} notes={n}"
|
|
for i, (t, a, n) in enumerate(_dead)))
|
|
check("the episode really was standing before the poll task died - otherwise "
|
|
"the release below is vacuous",
|
|
_before[1] and _before[2] == 1 and abs(_before[0]) >= rail_w(LIVE_INSTALL_MAX_W))
|
|
check("a dead poll task drives the command to 0 W",
|
|
bool(_zeroed) and _zeroed[0] == 0)
|
|
check("...and on THAT SAME cycle the alarm is already clear, because the "
|
|
"detector ran after 0 W was resolved into self.target, not before it",
|
|
bool(_zeroed) and not _dead[_zeroed[0]][1])
|
|
check("...and it stays clear for as long as the failsafe holds it there",
|
|
_dead and not any(a for _, a, _ in _dead))
|
|
# ⚠️ "and it raises nothing new while parked" is NOT asserted here, and the
|
|
# reason matters more than the deletion. It was dropped as REDUNDANT, not as
|
|
# undecidable: TEST-06's reviewer falsified it in one line - move the
|
|
# `hass.call` above `if not fired: return` so the detector notifies every cycle,
|
|
# and notifications appear in exactly these parked cycles (that mutation takes
|
|
# this file RED 9). My first note here claimed no mutation could break it, which
|
|
# was the very inference this file exists to distrust, made about this file.
|
|
# The deletion stands because the exact count below catches that same mutation
|
|
# nine times over - no coverage was lost - and the honest form of the claim is
|
|
# "I tried to break it and here is what happened", never "I could not, so it
|
|
# cannot".
|
|
|
|
# ⚠️ AND THE OTHER HALF, or "no second notification" is a claim no mutation can
|
|
# break: when the transport comes back against a meter that is STILL frozen the
|
|
# loop re-rails, and that is a new episode which MUST speak. What must not
|
|
# happen is a second notification stacking up beside the first.
|
|
_re_railed = [i for i, (t, _, _) in enumerate(_revived)
|
|
if abs(t) >= rail_w(LIVE_INSTALL_MAX_W)]
|
|
print(f" transport revived: command re-rails on cycle "
|
|
f"{_re_railed[0] if _re_railed else None}, "
|
|
f"{_ctl_d.freeze_wd.episodes} episode(s), {len(_freeze_notes)} "
|
|
f"notification(s) from this guard, "
|
|
f"ids {sorted({n.get('notification_id') for n in _freeze_notes})}")
|
|
# A PRECONDITION, not a discriminating check - the same role as "the command
|
|
# takes several cycles to reach the rail" above, and labelled the same way:
|
|
# without it the two checks below are satisfied by a controller that never came
|
|
# back. The crudest falsifier (never file the loop claim) takes most of this
|
|
# file red, which is the signature of a precondition rather than a check; the
|
|
# narrowest one measured is a stale-input stop that never releases.
|
|
check("PRECONDITION: a revived transport against a still-frozen meter re-rails "
|
|
"the command",
|
|
bool(_re_railed))
|
|
check("...which is a SECOND episode, and it alarms again on the cycle it rails",
|
|
_ctl_d.freeze_wd.episodes == 2
|
|
and bool(_re_railed) and _revived[_re_railed[0]][2] == _before[2] + 1)
|
|
# ⚠️ STABILITY, not the value - the value is pinned once, above. Renaming the
|
|
# id must fail that check and NOT this one; the two say different things and
|
|
# each has to be able to go red on its own.
|
|
check("...on the same notification_id, so the second REPLACES the first "
|
|
"instead of stacking a second one beside it",
|
|
len(_freeze_notes) == 2
|
|
and len({n.get("notification_id") for n in _freeze_notes}) == 1)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 6. what the status page SAYS about coverage (TEST-06 / AC2)
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ The check above only ever asserted that an uncovered transport is SILENT,
|
|
# which a status page claiming `armed` on a guard that cannot run also satisfies.
|
|
# The AC was that the detector reports uncovered transports *and says so*, so
|
|
# the string is what gets asserted here, in both directions: the uncovered row
|
|
# must not claim `armed`, and the covered row must still claim it - or "does not
|
|
# say armed" would be satisfiable by deleting the armed branch outright.
|
|
print("what the status page SAYS about coverage")
|
|
|
|
|
|
def freeze_rows(unchanged_s, grid, **over):
|
|
"""The freeze-detector rows of the real checks(), on a p1 install.
|
|
|
|
⚠️ WHAT THIS DOES NOT COVER. The rows live behind `if self.p1_enabled:`, and
|
|
the flag is set here by hand rather than by building a real P1 transport -
|
|
inherited from part 4's fixture. So everything below proves "WHEN the row is
|
|
produced it says the right thing", and nothing below proves "the row is
|
|
produced on homewizard_local and not otherwise". That gate is uncovered;
|
|
AC2 is about the string, and widening the fixture belongs to whoever covers
|
|
the gate.
|
|
"""
|
|
_h, ctl, _t = wired(unchanged_s, grid, cycles=3, **over)
|
|
# p1_enabled is what a homewizard_local install has; the row's own content
|
|
# is what is under test, not the transport that feeds it.
|
|
ctl.p1_enabled = True
|
|
return ctl, [r for r in ctl.checks()
|
|
if r["text"].startswith("freeze detector")]
|
|
|
|
|
|
_ctl_u, _rows_u = freeze_rows(None, 4000.0, meter_source="ha_dsmr")
|
|
_ctl_c, _rows_c = freeze_rows(2.0, -14.2)
|
|
print(f" uncovered: {_rows_u[0]['text'] if _rows_u else '(no row at all)'}")
|
|
print(f" covered: {_rows_c[0]['text'] if _rows_c else '(no row at all)'}")
|
|
check("an uncovered transport gets exactly one freeze-detector row",
|
|
len(_rows_u) == 1)
|
|
check("...and it says NOT COVERED",
|
|
bool(_rows_u) and "NOT COVERED" in _rows_u[0]["text"])
|
|
check("...and it does NOT claim the guard is armed - that would be the status "
|
|
"page lying on behalf of a guard that cannot run",
|
|
bool(_rows_u) and "armed" not in _rows_u[0]["text"])
|
|
check("...and it names the meter_source it is not covering, so the operator "
|
|
"knows which install this is",
|
|
bool(_rows_u) and "ha_dsmr" in _rows_u[0]["text"])
|
|
check("...and it names homewizard_local as the transport that would be covered",
|
|
bool(_rows_u) and "homewizard_local" in _rows_u[0]["text"])
|
|
check("...flagged as a warning rather than a silent ok",
|
|
bool(_rows_u) and not _rows_u[0]["ok"] and _rows_u[0]["warn"])
|
|
|
|
check("a COVERED transport gets exactly one freeze-detector row",
|
|
len(_rows_c) == 1)
|
|
check("...and that one DOES say armed - without this, 'the uncovered row does "
|
|
"not say armed' is satisfied by deleting the armed branch",
|
|
bool(_rows_c) and "armed" in _rows_c[0]["text"]
|
|
and "NOT COVERED" not in _rows_c[0]["text"])
|
|
check("...and it quotes the hold and the rail it is armed at, derived from the "
|
|
"config the controller was actually built with",
|
|
bool(_rows_c)
|
|
and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"]
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"])
|
|
check("...as an ok row, since a covered armed guard is not a warning",
|
|
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 5b. SAFETY-20 - is the device range actually WIRED into the guard? (AC1)
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ THIS SECTION EXISTS BECAUSE THE FIX SURVIVED ITS OWN MUTATION TEST WITHOUT
|
|
# IT. Deleting `dev_min=`/`dev_max=` from `check_meter_frozen()`'s call - i.e.
|
|
# shipping the corrected predicate and then not telling it the rating - left
|
|
# every check in 1c and 6b green, because those grade `freeze.py` and the status
|
|
# string and neither of them goes through `cycle()`. A guard that is right in the
|
|
# module and unwired in the caller is the defect with an extra step.
|
|
#
|
|
# So this drives the REAL `Controller.cycle()` on a controller configured the way
|
|
# the ticket describes - `max_w: 5000` on a +-4700 W inverter - and asserts on
|
|
# the notification that leaves the process.
|
|
#
|
|
# ⚠️ WHAT IT STILL DOES NOT COVER, stated rather than implied: the OTHER call
|
|
# site, `looks_frozen(...)` inside the SAFETY-07 peak-guard block, is behind
|
|
# `if self.p1_enabled` and needs a fed averager to have any observable
|
|
# consequence (`blindness()` returns BLIND_NO_BLOCK before it ever reads
|
|
# `frozen`). That gate is uncovered here for the same reason freeze_rows() says
|
|
# it is uncovered there, and widening the fixture belongs to whoever covers it.
|
|
print("SAFETY-20: the device range through the REAL cycle()")
|
|
|
|
_h20, _c20, _t20 = wired(3600.0, 4000.0, dev=(-4700.0, 4700.0))
|
|
_n20 = notes_of(_h20)
|
|
print(f" max_w 5000 on a +-4700 W device, meter frozen an hour: the command "
|
|
f"stood at {_c20.target:+.0f} W, {len(_n20)} freeze notification(s), "
|
|
f"watchdog active={_c20.freeze_wd.active}")
|
|
check("PRECONDITION: the device rating - not max_w - is what stopped the "
|
|
"command, so this really is the configuration the ticket is about",
|
|
abs(_c20.target) == 4700.0)
|
|
check("...and that command is BELOW the max_w rail, which is why the shipped "
|
|
"guard could never fire on this install",
|
|
abs(_c20.target) < rail_w(LIVE_INSTALL_MAX_W))
|
|
check("...and at or above the device rail, which is why it must",
|
|
abs(_c20.target) >= rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0))
|
|
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
|
|
"predicate from the controller, not just from a unit call",
|
|
len(_n20) == 1 and _c20.freeze_wd.active)
|
|
check("...and the notification the operator gets quotes the capped rail, "
|
|
"not the max_w one",
|
|
bool(_n20)
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
|
in str(_n20[0].get("message", "")))
|
|
# p1_enabled is what a homewizard_local install has - set by hand here for the
|
|
# same reason part 4 sets it, see freeze_rows().
|
|
_c20.p1_enabled = True
|
|
check("...and the red status row is up too, so the page and the notification "
|
|
"agree",
|
|
any("METER MAY BE FROZEN" in r["text"] for r in _c20.checks()))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 6b. SAFETY-20 - what the page says when the DEVICE is the ceiling (AC2)
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ THIS IS THE ACCEPTANCE CRITERION READ OFF THE RENDERED PAGE, NOT OFF THE
|
|
# SOURCE. The card is explicit that reading the code does not settle it, so
|
|
# every assertion below goes through the real `Controller.checks()` on a
|
|
# controller that was CONFIGURED with a device smaller than its `max_w` and
|
|
# then run - three real cycles, the real arbiter, the real write path.
|
|
print("what the page says when the device rating is the smaller ceiling")
|
|
|
|
# max_w 5000 on a 4700 W inverter - the ticket's own install. Covered transport,
|
|
# so the only question is what the ARMED row says.
|
|
_ctl_s, _rows_s = freeze_rows(2.0, -14.2, dev=(-4700.0, 4700.0))
|
|
# And the degenerate end: a device that can command nothing at all.
|
|
_ctl_z, _rows_z = freeze_rows(2.0, -14.2, dev=(0.0, 0.0))
|
|
print(f" max_w 5000 on a +-4700 W device: {_rows_s[0]['text'] if _rows_s else '(no row)'}")
|
|
print(f" a device with no commandable range: {_rows_z[0]['text'] if _rows_z else '(no row)'}")
|
|
|
|
check("a device smaller than max_w still gets exactly one freeze-detector row",
|
|
len(_rows_s) == 1)
|
|
check("...and the page does NOT print the max_w rail the command can never "
|
|
"reach - THE DEFECT, in one assertion",
|
|
bool(_rows_s)
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" not in _rows_s[0]["text"])
|
|
check("...it prints the rail the predicate actually uses",
|
|
bool(_rows_s)
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
|
in _rows_s[0]["text"])
|
|
check("...and it names the device range as the reason, since the operator "
|
|
"configured max_w and is owed the reason the guard is armed elsewhere",
|
|
bool(_rows_s) and "4700" in _rows_s[0]["text"]
|
|
and "max_w" in _rows_s[0]["text"])
|
|
|
|
# ⚠️ THE NUMBER IS TAKEN OFF THE ROW AND FED BACK INTO THE SHIPPED PREDICATE.
|
|
# Asserting the page against `rail_w(...)` alone proves the two agree, which a
|
|
# revert of BOTH to bare max_w also satisfies. What a revert cannot satisfy is
|
|
# this: the rail the page advertises has to be a magnitude the wire can carry,
|
|
# and the wire stops at the device rating.
|
|
_m = re.search(r"command is over (\d+(?:\.\d+)?) W", _rows_s[0]["text"]) if _rows_s else None
|
|
_page_rail = float(_m.group(1)) if _m else None
|
|
print(f" the page advertises a rail of {_page_rail} W against a device that "
|
|
f"stops at {_ctl_s.dev_max:.0f} W")
|
|
check("...the row states a rail in watts at all, so the parse below means "
|
|
"something", _page_rail is not None)
|
|
check("...and THE RAIL THE PAGE ADVERTISES IS ONE THE WIRE CAN ACTUALLY CARRY. "
|
|
"This is the whole ticket: 4750 W on a device that stops at 4700 W is a "
|
|
"guard that cannot fire, advertising that it can",
|
|
_page_rail is not None and _page_rail <= abs(_ctl_s.dev_max))
|
|
check("...and a command at exactly that advertised rail, with the meter frozen, "
|
|
"really does trip the shipped predicate the row is describing",
|
|
_page_rail is not None
|
|
and looks_frozen(3600, _page_rail, LIVE_INSTALL_MAX_W, cfg,
|
|
dev_min=_ctl_s.dev_min, dev_max=_ctl_s.dev_max))
|
|
check("...as an ok row - a guard armed at a lower rail is still armed, not a "
|
|
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
|
|
|
|
check("a device with no commandable range gets exactly one row", len(_rows_z) == 1)
|
|
check("...and it says NOT COVERED - the rail is not merely low there, it is "
|
|
"0 W, which would be TRUE of every command including a parked one",
|
|
bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"])
|
|
check("...and it NEVER says armed",
|
|
bool(_rows_z) and "armed" not in _rows_z[0]["text"])
|
|
check("...flagged as a warning rather than a silent ok, like the uncovered "
|
|
"transport row it shares",
|
|
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
|
|
check("...and the predicate agrees with the page rather than firing behind it",
|
|
not looks_frozen(3600, 0.0, LIVE_INSTALL_MAX_W, cfg,
|
|
dev_min=_ctl_z.dev_min, dev_max=_ctl_z.dev_max))
|
|
|
|
|
|
# AC3's other half, through the built object rather than through a literal: the
|
|
# opts dict does NOT carry meter_freeze_s, so this is the number an install that
|
|
# leaves the key at its config.yaml default actually lands on.
|
|
print(f" a Controller built with meter_freeze_s ABSENT holds "
|
|
f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s (config.yaml ships "
|
|
f"{SHIPPED_HOLD_S:.0f} s)")
|
|
check("a Controller built with the key absent lands on the SHIPPED hold, so "
|
|
"config.yaml and main.py's fallback cannot drift apart unnoticed",
|
|
_ctl_c.freeze_wd.cfg.hold_s == SHIPPED_HOLD_S)
|
|
|
|
print()
|
|
print(f"part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|