Files
goodwe-addon/goodwe_controller/test_clamp.py
T
glenn schrooyenandClaude Opus 5.5 1f97c7652f
checks / unit (pull_request) Failing after 9m32s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:27 +02:00

2155 lines
112 KiB
Python

"""Runnable check for SAFETY-03, the output clamp and its winding-loop alarm.
python3 test_clamp.py
No framework, asserts only, same house style as test_freeze.py.
Seven parts, and parts 3 and 4 are the ones that carry SAFETY-03's own
acceptance criteria; part 6 is TEST-11's and part 7 is SAFETY-28's:
1. THE CLAMP ARITHMETIC, on the shipped `clamp_to_rating()` - boundaries,
symmetry, the device second stage, and the non-finite cases. Table driven.
2. THE ALARM'S CADENCE - rising edge, re-assert, hysteresis - on the shipped
`ClampWatchdog`. Every engagement counted, notifications not flooded.
3. AC 1 + AC 3, WIRED. A computed setpoint that exceeds the rating, driven
through the REAL `Controller.cycle()`: nothing over the rating reaches the
device, and the alarm fires. Includes the historical runaway from
specs.md 10.3 replayed against an unclamped transcription, so the check
says what the clamp is actually worth in watts rather than asserting a
constant.
4. AC 2, AND IT IS THE LOAD-BEARING HALF. A loop that SATURATES WITHOUT
CLAMPING - the inverter refusing to follow a perfectly legal command - must
raise NO clamp alarm. If it does, the bug signal is inside the noise again
and the whole ticket has bought nothing.
5. NO PATH BYPASSES IT. write_setpoint() is the only route to the wire, so it
is called directly with an out-of-range value the way the heartbeat and the
operator's buttons call it.
6. TEST-11. THE GUARD READS THE WRITE THAT JUST HAPPENED. `self.clamp_last`
is assigned by `write_setpoint()` and by nothing else, so a
`check_output_clamp()` moved above the write keeps its loop-side evidence
and silently loses its write-side half. Graded as a CONSEQUENCE - what the
watchdog could see when it was consulted - so that a rename or a helper
does not trip it, and with the write-path half exercised on a stimulus of
its own, because parts 3-5 never produce one through cycle().
7. SAFETY-28. THE WRITE PATH IS GATED ON THE ALLOCATION RECORD - a value the
single allocator did not place on THIS tick does not reach the wire, a
record from a previous tick cannot authorise one, and 0 W is authorised
unconditionally so the failsafe can never deadlock behind the gate. This is
the check that replaced a config-shape proxy: `ACTUATOR_WRITE_KEYS` graded
how many option KEYS exist, which stays 1 while a per-inverter config adds
a second real writer.
9. SAFETY-40. The integrator bound (normal anti-windup) is reported in its
own words and never as a BUG SIGNAL; the over-rating keeps it; and every
number in either message is the clamp tick's own Decision.
------------------------------------------------------------------------------
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
`Decision.reason` is one string and the stages after the clamp overwrite it. The
check below drives a cycle that clamps the integrator AND is then slew limited,
and asserts that `reason` does NOT say so while `i_clamped` does. That is not a
style preference - an alarm keyed on the string is silent on exactly the cycles a
winding loop produces, and this is the check that would go red if someone
"simplified" main.py back onto it.
------------------------------------------------------------------------------
WHAT THIS CHECK DOES NOT COVER
- The firmware-side magnitude clamp (the card's "two clamps, not one"). This
add-on's `write_setpoint()` is the add-on half; the ESP32 half is DRIVER-07's
and is not in this repo's Python.
- A rig run. Nothing here needs one: the clamp is arithmetic on the write path
and part 3 drives the shipped `Controller.cycle()` against a stub Home
Assistant, which is the same surface test_freeze.py part 1-2 use. The wire-
level invariant the card asks for (`|emitted| <= rating` on every frame) is
asserted here over every value that reached `set_number()`, which is the last
thing this process controls; proving it at the inverter needs the witness log
and belongs with DRIVER-07.
⚠️ 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 io
import math
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from named_absence import given, nth, present
from app import messages
from app.clamp import (
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampResult,
ClampWatchdog,
clamp_to_rating,
is_commandable,
)
from app.allocator import Allocation, UnitPlan, plan
from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
from app.control import Tuning, compute
from app.inverter import Inverter
from app.main import Controller
from app import shadow
fails = []
T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
# The live install's numbers. NOTES.md and freeze.py both say max_w: 5000 there,
# which is also the device rating, so this is the bound the alarm is written at.
LIVE_MAX_W = 5000.0
CLAMP_ID = "goodwe_output_clamp"
# SAFETY-40: the integrator-bound WARNING has its own id, so it can never
# replace a BUG SIGNAL notice still on screen.
BOUND_ID = "goodwe_integrator_bound"
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# ==============================================================================
print("1. the clamp arithmetic, on the shipped expression")
# (requested, limit, expected applied, expected engaged, expected bound)
TABLE = [
(5001.0, 5000.0, 5000.0, True, "max_w"), # +limit+1
(-5001.0, 5000.0, -5000.0, True, "max_w"), # -limit-1, the export side
(5000.0, 5000.0, 5000.0, False, ""), # exactly +limit: NOT engaged
(-5000.0, 5000.0, -5000.0, False, ""), # exactly -limit: NOT engaged
(0.0, 5000.0, 0.0, False, ""), # the failsafe value
(1234.0, 5000.0, 1234.0, False, ""),
(14768.0, 5000.0, 5000.0, True, "max_w"), # 10.3's reported runaway
]
for requested, limit, want_applied, want_engaged, want_bound in TABLE:
r = clamp_to_rating(requested, limit)
check(f"{requested:+.0f} W against a {limit:.0f} W rating -> "
f"{r.applied_w:+.0f} W, engaged={r.engaged}, bound={r.bound!r}",
r.applied_w == want_applied and r.engaged is want_engaged
and r.bound == want_bound)
# It is a MAGNITUDE clamp (3.2), so import and export must be bound identically.
# Derived over a sweep rather than asserted on two hand-picked values.
asym = [m for m in range(0, 12001, 37)
if clamp_to_rating(m, LIVE_MAX_W).applied_w
!= -clamp_to_rating(-m, LIVE_MAX_W).applied_w]
check(f"symmetric over {len(range(0, 12001, 37))} magnitudes "
f"({len(asym)} asymmetric)", not asym)
# The engagement flag has to mean "the RAW value differed", which is the words
# AC 2 uses. Re-derived from the result's own fields, not from the table.
mismatch = [m for m in range(0, 12001, 37)
for r in [clamp_to_rating(m, LIVE_MAX_W)]
if r.engaged != (r.requested_w != r.applied_w)]
check("engaged is exactly 'raw differs from applied', over the same sweep",
not mismatch)
# The device second stage. Only reachable when max_w is ABOVE the device range,
# i.e. when validate_startup_config() did not run or no longer holds.
r = clamp_to_rating(3000.0, 5000.0, dev_min=-2000.0, dev_max=2000.0)
check(f"a device narrower than max_w still bites, and says which bound "
f"({r.applied_w:+.0f} W, {r.bound!r})",
r.applied_w == 2000.0 and r.engaged and r.bound == "device")
r = clamp_to_rating(3000.0, 5000.0, dev_min=-5000.0, dev_max=5000.0)
check("...and is a no-op once the startup validation holds",
r.applied_w == 3000.0 and not r.engaged)
# ⚠️ REGRESSION, REVIEW-FOUND. THE FIRST STAGE USED TO `return` AND THE DEVICE
# STAGE WAS THEN NEVER REACHED, so the harder the overshoot the less the guard
# did: at max_w 5000 against a +-4000 device, +9999 emitted +5000 - OUTSIDE THE
# DEVICE RANGE - while a mere +4500 was correctly held to +4000. That inverts
# the guard and it was a regression against the pre-PR `write_setpoint()`, whose
# comment read "never send a value the device will reject outright".
#
# The property is asserted over a sweep, not on the two witness values, because
# the defect was invisible to every single-value check that happened to sit
# inside one stage.
DEV_LO, DEV_HI = -4000.0, 4000.0
escapes = [(m, clamp_to_rating(m, 5000.0, DEV_LO, DEV_HI).applied_w)
for m in range(-15000, 15001, 61)
for a in [clamp_to_rating(m, 5000.0, DEV_LO, DEV_HI).applied_w]
if not (DEV_LO <= a <= DEV_HI)]
big = clamp_to_rating(9999.0, 5000.0, DEV_LO, DEV_HI)
small = clamp_to_rating(4500.0, 5000.0, DEV_LO, DEV_HI)
print(f" max_w 5000 vs a device of {DEV_LO:+.0f}..{DEV_HI:+.0f}: "
f"+9999 -> {big.applied_w:+.0f} W ({big.bound}), "
f"+4500 -> {small.applied_w:+.0f} W ({small.bound})")
check(f"BOTH stages always run - {big.requested_w:+.0f} W emits "
f"{big.applied_w:+.0f} W, inside the device's {DEV_HI:+.0f} W, and names "
f"both bounds ({big.bound!r})",
big.applied_w == DEV_HI and big.bound == "max_w+device")
check(f"...and it holds over the whole range, so no overshoot escapes the "
f"device ({len(escapes)} escapes in {len(range(-15000, 15001, 61))})",
not escapes)
check("...a big overshoot is not clamped LESS than a small one",
abs(big.applied_w) <= abs(small.applied_w) + 0.0)
print(" non-finite input is loss of input, not an out-of-range value")
for bad in (None, float("nan"), float("inf"), float("-inf"), "abc", object()):
check(f"is_commandable({bad!r:.30}) is False", is_commandable(bad) is False)
for good in (0, -5000.0, 5000, 12.5):
check(f"is_commandable({good!r}) is True", is_commandable(good) is True)
# ⚠️ WHY THAT GUARD EXISTS, AND THE ANSWER IS WORSE THAN "it passes a nan
# through". MEASURED HERE, not recalled: every comparison against a nan is
# False, so `min(limit, nan)` keeps its FIRST argument and a naive clamp turns a
# nan into a command at the FULL RATING - and then reports engaged=True, because
# limit != nan, so the winding-loop counter gets a transport bug filed in it.
# The number below is computed by the same expression the guard replaces.
nan = float("nan")
naive = max(-LIVE_MAX_W, min(LIVE_MAX_W, nan))
check(f"a naive clamp turns a nan into {naive:+,.0f} W and calls it "
f"engaged={naive != nan} - hence the guard",
naive == LIVE_MAX_W and (naive != nan) is True)
# ==============================================================================
print()
print("2. the alarm's cadence: every engagement counted, no flood")
wd = ClampWatchdog()
t = T0
fired = [wd.note(True, "winding", t + timedelta(seconds=i)) for i in range(10)]
check(f"the rising edge fires ({fired[0]})", fired[0] is True)
check(f"the next 9 one-second cycles fire {sum(fired[1:])} more times "
f"(re-assert is {REASSERT_S:.0f} s)", sum(fired[1:]) == 0)
check(f"but every one of them is counted: engagements={wd.engagements}",
wd.engagements == 10)
check(f"and it is one episode, not ten: episodes={wd.episodes}",
wd.episodes == 1)
# --- THE RE-ASSERT CADENCE, PINNED IN BOTH DIRECTIONS ------------------------
# ⚠️ REVIEW-FOUND. This used to read `wd.note(True, ..., T0 + REASSERT_S + 9)`,
# which is green for ANY value of the constant - REASSERT_S = 3600 passed, and an
# hour of silence is precisely what the module promises not to do. A check whose
# stimulus is written in terms of the constant it is grading cannot grade it.
#
# The requirements are stated in wall-clock seconds and derived from what the
# alarm is FOR, so they do not move when the constant does:
#
# floor - a loop pinned on the rail must speak at least MIN_PER_QUARTER times
# inside the 900 s quarter that bills the capacity tariff. That window
# is the same one freeze.py and DOCS.md reason about.
# ceiling - it must not speak on more than MAX_FRACTION of cycles, or it is
# the per-cycle flood the split exists to remove.
QUARTER_S = 900.0
MIN_PER_QUARTER = 5
MAX_FRACTION = 0.20
pinned = ClampWatchdog()
alarms = sum(1 for i in range(int(QUARTER_S))
if pinned.note(True, "pinned", T0 + timedelta(seconds=i)))
print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
f"{pinned.engagements} engagements, {alarms} alarm(s) "
f"({alarms / QUARTER_S:.1%} of cycles)")
check(f"a loop pinned for a whole billing quarter speaks at least "
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
alarms >= MIN_PER_QUARTER)
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
# alarm more than a real quarter earns. Measured on this very check:
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
# anyone raising the constant to 200 s would get a green suite and conclude the
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
check(f"...and the CADENCE itself honours the floor, independent of where the "
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
check("...while every single cycle is still counted",
pinned.engagements == int(QUARTER_S))
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
# still-engaged path. A condition that engages and clears repeatedly therefore
# raised one notification PER EPISODE at any value of the constant.
#
# That is what the live install produced: 48 engagements in ~25 minutes,
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
# them. The interval now bounds the notification RATE across episodes.
#
# The stimulus is written in wall-clock seconds and in the shape the field
# actually produced, so it does not move when the constant does.
SCATTER_CYCLE_S = 5.0
SCATTER_ONE_IN = 6
SCATTER_CYCLES = 305
scat = ClampWatchdog()
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
f"episodes, not once per episode ({scat_alarms} alarms for "
f"{scat.episodes} episodes)",
scat_alarms < scat.episodes)
check(f"...and the rate honours the same interval the pinned case does: at most "
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
f"{int(_scat_span // REASSERT_S) + 1}",
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
check("...while EVERY episode and EVERY engagement is still counted - the "
"budget is on notifications, never on evidence",
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
# object, so it only ever exercised the `last_alarm is None` branch, which is the
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
# That is the third instance in this file of the family recorded at line 216 and
# at the 224 s sweep, and it was guarding the most safety-critical property in
# the change. Review found it.
#
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
# a gap long enough to de-assert, then a continuous runaway. Without the
# escalation clause the runaway's first notification is held for up to
# REASSERT_S - measured at 140 s before it was added.
_esc = ClampWatchdog()
_esc.note(True, "benign", T0) # spends the budget
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
for _j in range(200)
if _esc.note(True, "runaway",
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
print(f" budget spent by a benign episode, then a continuous runaway from "
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={nth(_esc_fires, 0):.0f}s "
f"(delay {given(_esc_delay):.0f}s)")
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
f"spent the budget - it is not held for REASSERT_S ({given(_esc_delay):.0f}s "
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
check("...and it speaks at the escalation boundary rather than immediately, "
"which is what keeps the scattered flood suppressed",
_esc_delay is not None and _esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
# alarm never fires again. That is the "alarm that never fires" class, which is a
# different bug from one that fires later, and the suite was green on it.
_starve = ClampWatchdog()
_starve_fires = sum(1 for _i in range(1600)
if _starve.note(_i % 4 == 0, "chatter",
T0 + timedelta(seconds=5.0 * _i)))
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
f"8000 s, not 1)",
_starve_fires > 10)
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
# "0 clean cycles do NOT clear it". Green for every value >= 1 - including 1,
# which IS the chatter the constant exists to prevent. The stimulus is a literal
# now: ONE clean cycle must not clear the alarm, whatever the constant says.
wd2 = ClampWatchdog()
wd2.note(True, "winding", T0)
wd2.note(False, "", T0 + timedelta(seconds=1))
check(f"ONE clean cycle does NOT clear the alarm (active={wd2.active}) - "
f"a command dithering across the rail must not chatter",
wd2.active is True)
survived = 1
while wd2.active and survived < 50:
wd2.note(False, "", T0 + timedelta(seconds=1 + survived))
survived += 1
print(f" it took {survived} consecutive clean cycles to de-assert "
f"(CLEAR_CYCLES = {CLEAR_CYCLES})")
check(f"...and it does de-assert, after {survived} of them, rather than "
f"latching", wd2.active is False and survived == CLEAR_CYCLES)
check("...and the engagement count survives the clear, so the evidence does",
wd2.engagements == 1)
check("a clean cycle on an idle watchdog says nothing",
wd2.note(False, "", T0 + timedelta(seconds=300)) is False)
# ==============================================================================
print()
print("3. AC 1 + AC 3, wired: over the rating both clamps AND alarms")
class _Store:
data = {}
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:
"""Stub HA. `track` False models an inverter that is switched off entirely,
which is the 10.3 runaway's own setup."""
def __init__(self, grid, batt=0.0, track=True):
self.grid = grid
self.batt = batt
self.track = track
self.calls = []
self.writes = []
async def limits(self, entity):
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
async def number(self, entity, invert=False):
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))
if self.track:
self.batt = float(value)
return True
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
return True
def wired(grid, cycles=40, batt=0.0, track=True, **over):
async def go():
hass = _Hass(grid, batt=batt, track=track)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
# The red status row exists only WHILE the clamp is engaged, and the
# hysteresis clears it a few cycles later - so it is captured live
# rather than looked for after the run, when it is correctly gone.
c.red_snapshot = []
for _ in range(cycles):
await c.read_inputs()
await c.cycle()
if c.clamp_wd.active and not c.red_snapshot:
c.red_snapshot = c.checks()
return hass, c
return asyncio.run(go())
def clamp_notes(hass):
return [d for dom, svc, d in hass.calls
if (dom, svc) == ("persistent_notification", "create")
and d.get("notification_id") in (CLAMP_ID, BOUND_ID)]
# THE 10.3 RUNAWAY, replayed. "With the inverter switched off entirely, an
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
# reports 0 W however hard it is commanded, so the error never closes.
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
emitted = [abs(w) for w in hass_r.writes]
# What the SAME error would have done to an accumulator with no bound - the
# transcription is deliberately the unclamped velocity form 10.3 describes, so
# the figure below is computed here and not quoted from the spec.
runaway, err = 0.0, 6000.0
for _ in range(60):
runaway += 0.6 * err
print(f" unclamped, the same 60 cycles reach {runaway:,.0f} W; "
f"the largest value that reached the device is {max(emitted):,.0f} W")
check(f"an unbounded accumulator really does run away here "
f"({runaway:,.0f} W >> {LIVE_MAX_W:.0f} W)", runaway > 3 * LIVE_MAX_W)
check(f"AC 1: NOT ONE of the {len(emitted)} values that reached the device "
f"exceeds the {LIVE_MAX_W:.0f} W rating",
emitted and max(emitted) <= LIVE_MAX_W)
# ⚠️ AND THE CLAMP IS NOT THE ONLY THING HOLDING THAT LINE HERE, which is worth
# saying out loud rather than letting the number above imply otherwise. Against
# an inverter reporting 0 W the saturation freeze engages after
# `saturation_cycles` and pins the command well BELOW the rail - that is why the
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
# bounded; the freeze is what makes it small. A check that read
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
# SAFETY-52 re-baseline: an inverter reporting 0 W for SETTLE_CYCLES is a
# battery that refuses, so the command then drops to 0 W. The freeze still
# engaged first, and the event log says so.
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
f"rather than the rail",
any("saturation freeze" in e for e in ctl_r.events)
and max(emitted) < LIVE_MAX_W)
notes_r = clamp_notes(hass_r)
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
f"in {ctl_r.clamp_wd.episodes} episode(s), raising {len(notes_r)} "
f"notification(s)")
check("AC 3: and it ALARMED - silently clipping this is the defect",
ctl_r.clamp_wd.engagements > 0 and len(notes_r) >= 1)
check("...with its own notification_id, not shared with any other guard - "
"on a default install the integrator bound's (SAFETY-40)",
all(d["notification_id"] == BOUND_ID for d in notes_r))
check("...one episode, re-asserted rather than one per cycle",
ctl_r.clamp_wd.episodes == 1 and len(notes_r) < ctl_r.clamp_wd.engagements)
# SAFETY-40: on a default install this runaway is the INTEGRATOR bound, so it
# says so, and does not call anti-windup a bug (part 8 grades the wording).
check("...and it is the integrator-bound notice, which calls nothing a fault",
bool(notes_r) and notes_r[0]["title"] == messages.T_LOOP_BOUND
and "fault" not in notes_r[0]["message"])
rows_r = [e for e in ctl_r.events if REASON_CODE in e]
check(f"the action log records every engagement, coalesced into one row per "
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
f"engagement(s))",
len(rows_r) == ctl_r.clamp_wd.episodes
and messages.count(ctl_r.clamp_wd.engagements, "engagement")
in rows_r[0])
# ⚠️ REGRESSION, AND IT WAS A REAL BUG BEFORE IT WAS A CHECK. `log_event()` keeps
# 40 rows. A row per engagement flushes that ring inside 40 cycles of a winding
# loop, and the first thing it evicted was SAFETY-18's own "METER FROZEN?" row -
# i.e. this guard silencing another guard's log. Driven here with a plant that
# TRACKS the command, so the saturation freeze never engages and the integrator
# clamps on essentially every cycle.
hass_l, ctl_l = wired(9000.0, cycles=80, track=True)
rows_l = [e for e in ctl_l.events if REASON_CODE in e]
other_l = [e for e in ctl_l.events if REASON_CODE not in e]
print(f" 80 winding cycles: {ctl_l.clamp_wd.engagements} engagement(s) -> "
f"{len(rows_l)} log row(s), leaving room for {len(other_l)} other event(s)")
check(f"a long winding episode does NOT flood the 40-row event log "
f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)",
ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes)
check("...and the count in the row is the counter's, so nothing is lost",
messages.count(ctl_l.clamp_wd.engagements, "engagement")
in nth(rows_l, -1))
# The RED row exists only while engaged, and the episode above is short, so it
# is captured mid-run rather than asserted after the hysteresis has cleared it.
check("the status page flags it WHILE it is engaged - amber, since it is the "
"integrator bound (SAFETY-40; red is part 9's over-rating case)",
any(not c["ok"] and c["warn"] and c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
for c in ctl_r.red_snapshot))
check("...and once clear, a warn row still says how many times it engaged, "
"under the integrator bound's own title",
any(c["warn"] and c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
and messages.times(ctl_r.clamp_wd.bound) in c["text"]
and "fine now" in c["text"] for c in ctl_r.checks()))
# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
# time(s)" for integrator-bound cycles. Each count now sits under its own name.
check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
"row calls them 'Power request too high'",
ctl_r.clamp_wd.bound == ctl_r.clamp_wd.engagements > 0
and ctl_r.clamp_wd.over_rating == 0
and not any(c.get("id") == "power_too_high"
or messages.T_OUTPUT_CLAMP in c["text"]
for c in ctl_r.checks()))
# ...and an earlier over-rating count stays on the page while a LATER bound
# episode runs: it is the software-fault evidence, and that episode (bug False)
# cannot have added to it, so "fine now" is true of it.
_w = ctl_r.clamp_wd
_saved = (_w.active, _w.bug, _w.over_rating)
_w.active, _w.bug, _w.over_rating = True, False, 2
_rows = ctl_r.checks()
_w.active, _w.bug, _w.over_rating = _saved
check("UX-07 item 1: during a bound episode an earlier over-rating count is "
"still shown, amber, under its own title",
any(c.get("id") == "not_keeping_up" for c in _rows)
and any(c.get("id") == "power_too_high" and c["warn"] and not c["ok"]
and c["text"] == messages.row_before(messages.T_OUTPUT_CLAMP, 2)
for c in _rows))
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
# keeps writing the clamped value. If a later cut changes that it must come here.
# SAFETY-52: the loop itself now asks 0 W of an inverter that gives nothing,
# with its own reason. That is the loop's decision, not a stop.
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W because "
f"the loop says '{ctl_r.loop_reason}', not because of a stop",
ctl_r.loop_reason == "battery refuses"
and ctl_r.target == hass_r.writes[-1])
check("...and the controller still reports itself running, not failsafe",
ctl_r.auto and not ctl_r.sign_wd.tripped)
# The export side, because the clamp is a magnitude clamp and a sign bug in it
# would be invisible on the import case alone.
hass_x, ctl_x = wired(-6000.0, cycles=60, track=False)
check(f"the export side clamps and alarms too "
f"({min(hass_x.writes):+,.0f} W, {len(clamp_notes(hass_x))} note(s), "
f"{ctl_x.clamp_wd.engagements} engagement(s))",
min(hass_x.writes) < 0 and abs(min(hass_x.writes)) <= LIVE_MAX_W
and clamp_notes(hass_x) and ctl_x.clamp_wd.engagements > 0)
# ==============================================================================
print()
print("4. AC 2: saturation WITHOUT clamping must NOT raise a clamp alarm")
# A legal command the inverter will not follow. The meter sits just outside the
# deadband so the loop moves a little and then rests; `batt` is pinned far from
# whatever is commanded, which is the saturation condition (|cmd - actual| >
# saturation_w) and nothing else. The integrator never approaches the rating.
hass_s, ctl_s = wired(60.0, cycles=40, batt=2500.0, track=False)
peak = max(abs(w) for w in hass_s.writes)
print(f" saturated for {ctl_s.sat_count} consecutive cycles; the command "
f"peaked at {peak:.0f} W, {peak / LIVE_MAX_W:.1%} of the rating")
# ⚠️ THE POSITIVE HALF FIRST. A negative check that saw nothing happen is not an
# observation - if the saturation freeze never engaged, "no clamp alarm" is
# vacuous and this check is worthless.
check(f"the saturation freeze REALLY DID engage (sat_count="
f"{ctl_s.sat_count} >= {ctl_s.tuning.saturation_cycles})",
ctl_s.sat_count >= ctl_s.tuning.saturation_cycles)
check("...and the event log says so, in the saturation's own words",
any("saturation freeze" in e for e in ctl_s.events))
check(f"...while the command stayed well inside the rating "
f"({peak:.0f} W < {LIVE_MAX_W:.0f} W)", peak < LIVE_MAX_W)
# Now the negative, which is only worth anything because of the three above.
check("AC 2: NO clamp notification was raised", not clamp_notes(hass_s))
check("AC 2: the clamp counted ZERO engagements", ctl_s.clamp_wd.engagements == 0)
check("AC 2: no OUTPUT_CLAMP row in the action log",
not [e for e in ctl_s.events if REASON_CODE in e])
check("AC 2: and no clamp row on the status page",
not [c for c in ctl_s.checks()
if c.get("id") in ("power_too_high", "not_keeping_up")])
print(" ...and the two alarms do not share a notification_id")
ids_clamp = {d["notification_id"] for d in clamp_notes(hass_r)}
ids_other = {d.get("notification_id") for dom, svc, d in hass_r.calls
if svc == "create"} - ids_clamp
check(f"clamp ids {sorted(ids_clamp)} are disjoint from every other alarm's "
f"{sorted(x for x in ids_other if x)}", not (ids_clamp & ids_other))
print(" ...and the alarm cannot be keyed on Decision.reason")
# A cycle that clamps the integrator AND is then slew limited. `reason` reports
# the LAST stage, so a string-keyed alarm is silent on it; the boolean is not.
t = Tuning(max_w=2000.0, slew_w=100.0, gain=0.6)
d = compute(prev_w=1000.0, grid_w=6000.0, actual_w=1000.0, tuning=t, i_w=1900.0)
print(f" reason={d.reason!r} i_clamped={d.i_clamped} clamped={d.clamped}")
check("the integrator DID clamp on that cycle", d.i_clamped is True)
check("...but `reason` says 'slew-limited' - the clamp was overwritten "
"downstream, so a string-keyed alarm is SILENT here",
d.reason == "slew-limited" and "clamp" not in d.reason)
# And the same for the OUTPUT clamp, on a config that lets the integrator run
# above the rail - then both flags fire and `reason` still reports neither.
t2 = Tuning(max_w=2000.0, integrator_max_w=3000.0, slew_w=100.0, gain=0.6)
d2 = compute(prev_w=1000.0, grid_w=6000.0, actual_w=1000.0, tuning=t2, i_w=2900.0)
print(f" reason={d2.reason!r} i_clamped={d2.i_clamped} clamped={d2.clamped}")
check("both clamps engaged, and `reason` reports neither",
d2.i_clamped and d2.clamped and "clamp" not in d2.reason)
# ⚠️ HOW THE FREEZE AND THE CLAMP INTERACT, MEASURED RATHER THAN ASSUMED, AND
# THE MEASUREMENT REFUSED TWO GUESSES BEFORE THIS ONE. Written first as "a
# frozen cycle never clamps" (false), then as "only the i_w == 0 reseed
# exception clamps" (also false - at i_w == 0 with a matching actual the cycle
# is not saturated at all, so it is never frozen). What the sweep actually
# shows: a frozen cycle clamps exactly when the integrator REVERSES. control.py
# permits that on purpose - "it may fall, cross zero, or reverse outright" -
# because the alternative deadlocks the loop, and a reversal is a full swing
# that can clear the bound on the far side in one step.
#
# That matters for this ticket rather than being trivia: it is the one shape
# where the saturation freeze and a clamp engagement coincide, so it is exactly
# where an alarm that shared a channel with saturation would lose the clamp.
grid_sweep = (-9000.0, -3000.0, 3000.0, 9000.0)
grid = [(pp, g) for pp in (-1900.0, -500.0, 0.0, 500.0, 1900.0)
for g in grid_sweep]
runs = {(pp, g): compute(pp, g, 0.0, t, sat_count=5, i_w=pp) for pp, g in grid}
frozen = [k for k, d in runs.items() if d.frozen]
frozen_clamps = [k for k in frozen if runs[k].i_clamped]
# The property, stated independently of the control law's own branches: the
# correction pushes AGAINST the standing integrator, and the resulting swing
# clears the bound.
reversals = [(pp, g) for pp, g in frozen
if pp * g < 0 and abs(pp + t.gain * g) > t.max_w]
print(f" {len(frozen)} of {len(grid)} sweep cells are frozen; "
f"those that also clamp: {frozen_clamps}")
check(f"a frozen cycle CAN clamp, and only by reversing "
f"({len(frozen_clamps)} of {len(frozen)} frozen cells)",
frozen_clamps and frozen_clamps == reversals)
check("...and a frozen cycle pushing the way it already leans never clamps - "
"that is the freeze doing its job",
not [k for k in frozen if runs[k].i_clamped and k[0] * k[1] > 0])
check("a healthy cycle sets neither flag",
not any([compute(0.0, 100.0, 0.0, t).i_clamped,
compute(0.0, 100.0, 0.0, t).clamped]))
# ==============================================================================
print()
print("5. no path reaches the wire around the clamp")
def arm(c, value):
"""Give `c` a real allocation record authorising `value` on the CURRENT tick.
⚠️ SAFETY-28 MADE THIS NECESSARY, AND THE FACT THAT IT IS NECESSARY IS THE
TICKET. `write_setpoint()` now refuses any value the single allocator did
not place on this tick, so the probes below - which call it directly, the
way the heartbeat and the operator's buttons do - have to say where their
number came from. Before this they did not, and that was precisely the hole:
a second writer's number reached the wire on nothing but the fact that
somebody called the method.
⚠️ AND IT IS BUILT THROUGH THE SHIPPED `resolve()`, NOT BY HAND. A helper
that assembled an `Allocation` itself would be a second allocator living in
the test file, and every check below would then be grading the fixture's
idea of a record rather than the one main.py produces. The tick is bumped
the same way `cycle()` bumps it, for the same reason.
"""
c.alloc_tick += 1
res = resolve([Claim.set("loop", P_LOOP, value, "test stimulus")],
units=c.units, live=c.units, tick=c.alloc_tick)
c.allocation = res.allocation
return res.allocation
async def _direct():
hass = _Hass(0.0)
# ⚠️ SAFETY-10: `shadow_mode: "off"` EXPLICITLY, BECAUSE THIS FIXTURE
# NEVER PRESSES START. The shipped default is `auto`, which is SHADOW
# MODE while control is stopped - so without this line the probe below
# would be graded against a controller that is correctly refusing to
# command anything, and the clamp it is here to exercise would never be
# reached. The shipped write path is unaffected: a stopped controller
# only ever writes 0 W in production, which shadow mode also writes.
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off", "shadow_mode": "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
# The shape the heartbeat, shutdown() and handle_action() all use - each one
# authorised by this tick's allocation, which is what those callers have
# too: the heartbeat re-affirms `self.target`, and the buttons write 0 W,
# which needs no allocator at all (see part 7).
for wanted in (9999.0, -9999.0):
arm(c, wanted)
await c.write_setpoint(wanted, force=True)
# A nan is loss of input, not an unauthorised write: rule 1 turns it into
# 0 W ABOVE the gate, and 0 W is always authorised. No arming, on purpose -
# if the gate ever moved above the coercion this line would go red.
await c.write_setpoint(float("nan"), force=True)
arm(c, 1500.0)
await c.write_setpoint(1500.0, force=True)
return hass, c
hass_d, ctl_d = asyncio.run(_direct())
print(f" asked for 9999 / -9999 / nan / 1500 W; the device saw "
f"{hass_d.writes}")
check("a direct out-of-range write is clamped, not passed through",
hass_d.writes[:2] == [2000.0, -2000.0])
check("a nan is refused and becomes 0 W, per 11.1 rule 1",
hass_d.writes[2] == 0.0)
check("...and is NOT counted as a clamp engagement - it is a transport bug",
sum(1 for e in ctl_d.events if REASON_CODE in e) == 2)
check("an in-range value is untouched", hass_d.writes[3] == 1500.0)
check("every emitted frame satisfies |emitted| <= the rating",
all(abs(w) <= 2000.0 for w in hass_d.writes))
# --- 5b. ALLOC-01: AND THE ALLOCATOR DID NOT ADD ONE ------------------------
# ⚠️ "NO PATH REACHES THE WIRE AROUND THE CLAMP" IS A CLAIM ABOUT THE WHOLE
# ADD-ON, so it has to be re-made every time a module learns to produce a number
# that looks like a setpoint. ALLOC-01 added `app/allocator.py`, whose output is
# a dict of per-inverter watts - numbers that are exactly the shape of a thing
# somebody would write - and the temptation it creates is real: a second writer
# for a second inverter is DEV-02's card, and the wrong way to build it is a
# `set_number` call next to the allocation.
#
# Two checks, and neither is about the allocator's arithmetic:
# * it CANNOT write, and that is established from its imports rather than
# from its behaviour, because a behavioural check only sees the paths a
# fixture happens to take;
# * at the shipped roster the number that reaches the wire is still the
# arbiter's `target_w`, unclamped by the allocator, so SAFETY-03's clamp is
# still the binding one and not a second opinion after a first clamp.
import app.allocator as _alloc_mod # noqa: E402
_alloc_src = io.open(_alloc_mod.__file__, encoding="utf-8").read()
_alloc_imports = sorted(
n.split(".")[0]
for line in _alloc_src.splitlines() if line.startswith(("import ", "from "))
for n in [line.split()[1]])
print(f" app/allocator.py imports: {_alloc_imports}")
check("the allocator imports NOTHING that can reach the wire - no `hass`, no "
"`aiohttp`, not even `main`. It is a pure function of intervals and a "
"number, so 'it cannot write' is a property of the file rather than of "
"the paths a fixture happened to take",
_alloc_imports == ["dataclasses", "math"])
check("...and it never mentions the setpoint entity, `set_number`, or the "
"clamp - the three names any bypass would have to spell",
not any(n in _alloc_src for n in ("set_number", "setpoint_entity",
"clamp_to_rating")))
check("...and it must not: it has no measurement either. The allocator reading "
"P1 would be a SECOND integrator on the site measurement, which is the "
"same two-writers failure one level up",
not any(n in _alloc_src for n in ("p1", "P1Ingest", "grid_w")))
check("at the shipped roster of ONE the allocator is a pass-through, so the "
"number handed to write_setpoint() is the arbiter's own target and "
"SAFETY-03's clamp is still the FIRST and only clamp it meets. An "
"allocator that pre-clamped here would leave this file grading a value "
"that was already in range, and the clamp untested on the live path",
allocate(9999.0, {"inverter-a": UnitBounds()}) == ({"inverter-a": 9999.0},
True))
check("...and that unclamped 9999 W is exactly what part 5 above just proved "
"the write path stops at the rating. The two halves meet here: the "
"allocator does not clamp, the wire does",
hass_d.writes[0] == 2000.0)
# ==============================================================================
print()
print("6. TEST-11: the guard's evidence is THIS cycle's write, not the last one")
# WHY THIS PART EXISTS. `check_output_clamp()` sits one line below
# `await self.write_setpoint(self.target)` in cycle(), and moving it above the
# write left this file, test_freeze.py and test_loop.py ALL GREEN. That is not a
# missing assertion about line numbers - it is a missing assertion about
# EVIDENCE. The guard has two sources:
#
# * `self.loop_clamped`, set from the Decision well ABOVE the write. Still
# seen wherever the call is put, which is why the move is nearly invisible:
# on the §10.3 replay above, every engagement the alarm reports is this one.
# * `self.clamp_last`, which ONLY `write_setpoint()` assigns (main.py) and
# which is None until it has run. This half is what loses its evidence.
#
# So the check below does not pin the call's POSITION IN THE SOURCE, and must
# not: the comment at that line warns against tidying SAFETY-03's ordering and
# SAFETY-18's into one rule, and a check a rename breaks is worse than no check.
# It pins the CONSEQUENCE - that when the guard is consulted, `self.clamp_last`
# is the result of the write of THIS cycle - and part 6b runs the identical
# assertions through a deliberately refactored controller to prove it.
class _SpyWatchdog(ClampWatchdog):
"""The shipped watchdog, plus a record of what the guard could SEE.
⚠️ IT HOOKS THE CONSUMER, NOT THE GUARD'S NAME. Wrapping
`check_output_clamp` by name would make a rename - AC 2's legitimate
refactor - look identical to the defect, and this ticket is explicitly not
allowed to buy its check with a nuisance. `clamp_wd.note()` is the one call
the guard exists to make, so it is hooked instead: it is reached from the
guard under any name, from any helper, as long as that helper runs after the
write.
"""
def __init__(self):
super().__init__()
self.owner = None
self.seen = [] # clamp_last.requested_w at each consultation
self.seen_write = [] # was the WRITE-path half engaged, per cycle
def note(self, engaged, reason, now, **kw):
cl = self.owner.clamp_last
self.seen.append(None if cl is None else cl.requested_w)
self.seen_write.append(bool(cl and cl.engaged))
return super().note(engaged, reason, now, **kw)
def probed(cls=Controller, grid=9000.0, cycles=5, **over):
"""`wired()`, with the watchdog replaced by the spy and the per-cycle
command returned. A RAMP is used on purpose: the slew limiter makes every
cycle's command a different number, which is what makes "this cycle's
evidence" and "last cycle's evidence" distinguishable at all."""
async def go():
# `track=True` - the plant follows, so the saturation freeze never
# engages and the slew limiter keeps the command MOVING; against a
# dead plant the freeze pins it flat after three cycles and the ramp
# stops being a ramp.
hass = _Hass(grid, track=True)
c = cls(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
c.clamp_wd = _SpyWatchdog()
c.clamp_wd.owner = c
asked = []
for _ in range(cycles):
await c.read_inputs()
await c.cycle()
asked.append(c.target)
return hass, c, asked
return asyncio.run(go())
def evidence_checks(label, asked, wd):
"""The same four claims, run against the shipped controller (6a) and against
a refactored one (6b). Sharing them is the point: if a rename or a helper
changed the answer, this function would say so in the same words."""
seen = wd.seen
print(f" {label}: asked "
f"{[f'{w:+.0f}' for w in asked]}")
print(f" {label}: the guard saw "
f"{['None' if s is None else f'{s:+.0f}' for s in seen]}")
check(f"{label}: PRECONDITION - the command MOVES on every cycle, so this "
f"cycle's evidence and last cycle's are different numbers",
len(set(asked)) == len(asked) and len(asked) >= 4)
check(f"{label}: the guard was consulted exactly once per cycle",
len(seen) == len(asked))
check(f"{label}: AC 1 - on EVERY cycle self.clamp_last held the result of "
f"the write THAT CYCLE. write_setpoint() is the only line that "
f"assigns self.clamp_last, so a check_output_clamp() moved above the "
f"write grades the PREVIOUS cycle's write instead",
seen == asked)
check(f"{label}: ...and on the FIRST cycle self.clamp_last was already set "
f"- before the run's first write it is None, i.e. the write-path half "
f"of the guard has no evidence at all",
bool(seen) and seen[0] is not None)
hass_e, ctl_e, asked_e = probed()
evidence_checks("shipped", asked_e, ctl_e.clamp_wd)
# ⚠️ AND HERE IS WHY AC 3 NEEDS A STIMULUS OF ITS OWN, MEASURED RATHER THAN
# ASSERTED. Through the real cycle() the write-path half never engages: the
# arbiter files the device range and max_w as P_SAFETY LIMIT claims, so
# `resolution.target_w` is already inside both bounds before write_setpoint()
# re-applies them (main.py calls that duplication defence in depth, and it is).
# Every engagement the alarm reports on this path is therefore the loop-side
# one - which is exactly what makes the move nearly invisible, and exactly why
# 6c below drives the write path directly instead of hoping cycle() will.
check(f"MEASURED: the write-path half engaged 0 times in {len(asked_e)} real "
f"cycles ({sum(ctl_e.clamp_wd.seen_write)}), so no cycle()-driven check "
f"can be watching it", not any(ctl_e.clamp_wd.seen_write))
class _RefactoredController(Controller):
"""AC 2, as a fixture rather than as a promise: the two refactors a P3 tidy
would actually make, applied at once.
* the guard is RENAMED to `_clamp_guard()`, the old name left as the thin
delegate such a rename leaves behind;
* the write MOVES INTO A HELPER, `_emit_setpoint()`.
Neither changes when the guard runs relative to the write, so every check in
`evidence_checks()` must stay green. If one of them goes red here, the check
is grading the text of main.py and has to be thrown away.
⚠️ THE SIGNATURE GAINED `unit=` IN DEV-02 AND THIS FIXTURE FOLLOWED IT, which
is the honest update and not a weakening: `write_setpoint()` now addresses ONE
inverter (or, with `unit=None`, commands the whole site to 0 W), because a
controller with a roster cannot have one write path that means "the
inverter". A delegate that dropped the argument would silently re-broadcast
at N>=2, so it is passed through here exactly as a real refactor would have
to pass it through.
"""
async def _emit_setpoint(self, value, *, force=False, unit=None):
await Controller.write_setpoint(self, value, force=force, unit=unit)
async def write_setpoint(self, value, *, force=False, unit=None):
await self._emit_setpoint(value, force=force, unit=unit)
async def _clamp_guard(self):
await Controller.check_output_clamp(self)
async def check_output_clamp(self):
await self._clamp_guard()
# ⚠️ VERIFIED AGAINST main.py ITSELF, NOT ONLY AGAINST THIS SUBCLASS. Renaming
# `check_output_clamp` throughout main.py AND moving both the write and the guard
# into a new `_emit_and_guard()` helper called from cycle() leaves every check in
# this part green; moving the guard above the write turns the two AC 1 lines
# below red. The one thing a repo-wide rename does cost is the direct call in 6c,
# which is a mechanical find/replace and fails as an AttributeError naming the
# new method - not as an assertion claiming the ordering is wrong, which is the
# false signal this part was written to avoid.
hass_f, ctl_f, asked_f = probed(cls=_RefactoredController)
evidence_checks("renamed + helper", asked_f, ctl_f.clamp_wd)
check("AC 2: the refactored controller behaves identically to the shipped one "
"- same commands, same evidence, so the check above is not pinning "
"either name",
asked_f == asked_e and ctl_f.clamp_wd.seen == ctl_e.clamp_wd.seen)
# --------------------------------------------------------------------------- #
# 6c. AC 3 - the WRITE-PATH half, exercised
# --------------------------------------------------------------------------- #
# The stimulus is SAFETY-20's shape and that is not a coincidence: `max_w` 5000
# on an inverter that reports +-2500 is the one configuration where the value
# put on the wire DIFFERS from the value the loop asked for while every loop-side
# flag stays clean. That difference is the whole content of `clamp_last`, and a
# DEVICE-bound engagement is the only kind `loop_clamped` cannot also see.
#
# It is driven through `write_setpoint()` and the guard directly rather than
# through cycle(), because the measurement above says cycle() cannot produce it
# today. That is stated as a limitation, not hidden: what is being graded here is
# the guard's own contract - given a write that clamped, alarm - and the ordering
# in cycle() is what 6a grades.
async def _write_path(moved=False):
"""`moved` runs the two calls in the order the ticket asks about."""
hass = _Hass(0.0)
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
"max_w": LIVE_MAX_W},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
# The inverter is SMALLER than the configured ceiling - SAFETY-20's install.
c.dev_min, c.dev_max = -2500.0, 2500.0
c.target = 3000.0
# SAFETY-28: the write path only carries numbers the allocator placed, so
# this stimulus is authorised the same way cycle()'s is. It has to be: the
# clamp is what this part grades, and a gate refusal would substitute 0 W
# and there would be no clamp to see.
arm(c, c.target)
if moved:
await c.check_output_clamp()
await c.write_setpoint(c.target)
else:
await c.write_setpoint(c.target)
await c.check_output_clamp()
return hass, c
hass_w, ctl_w = asyncio.run(_write_path())
notes_w = clamp_notes(hass_w)
print(f" max_w {LIVE_MAX_W:.0f} on a +-2500 W device, asked for "
f"{ctl_w.target:+.0f} W: the device saw {hass_w.writes[-1]:+.0f} W, "
f"bound={ctl_w.clamp_last.bound!r}, loop_clamped={ctl_w.loop_clamped[0]}")
check("PRECONDITION: the write really did clamp, on the DEVICE bound",
ctl_w.clamp_last is not None and ctl_w.clamp_last.engaged
and ctl_w.clamp_last.bound == "device"
and hass_w.writes[-1] == 2500.0)
check("...and the loop-side half is SILENT here, so self.clamp_last is the "
"only evidence that this happened at all",
ctl_w.loop_clamped == (False, "", False))
check("AC 3: the guard alarmed on write-path evidence alone",
ctl_w.clamp_wd.engagements == 1 and len(notes_w) == 1)
check("...and the operator is told WHICH bound bit, so a device-rating clamp "
"is not reported as a max_w one",
bool(notes_w)
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
check("...and the action log carries the row, written by write_setpoint()",
any(REASON_CODE in e and messages.LIMIT_WORDS["device"] in e
for e in ctl_w.events))
# The same stimulus, the same clamp, the two calls swapped. This is the ticket
# in one pair of runs.
hass_m, ctl_m = asyncio.run(_write_path(moved=True))
print(f" the same stimulus with the guard consulted BEFORE the write: "
f"{ctl_m.clamp_wd.engagements} engagement(s), "
f"{len(clamp_notes(hass_m))} notification(s), and the clamp still "
f"happened (engaged={ctl_m.clamp_last.engaged})")
check("THE DEFECT, DEMONSTRATED: consulted before the write the guard is "
"SILENT - self.clamp_last is still None at that point - while the wire "
"carries a clamped value either way",
not clamp_notes(hass_m) and ctl_m.clamp_wd.engagements == 0
and ctl_m.clamp_last.engaged and hass_m.writes[-1] == 2500.0)
check("...and main.py:856's consumer is the line that goes quietly false",
bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged))
# --------------------------------------------------------------------------- #
# 6d. SAFETY-38 round 4 - THE ORDERED PAIR INSIDE `ClampResult.describe()`
# --------------------------------------------------------------------------- #
# ⚠️ THIS IS THE ALARM THE OWNER HAS BEEN COMPLAINING ABOUT ALL DAY, AND UNTIL
# THIS BLOCK NO test_*.py MENTIONED `describe()` AT ALL. `clamp.py:253` builds
# the whole §15 action-log row and the whole ClampWatchdog notification reason
# out of its own attributes:
#
# f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W was
# clamped to {self.applied_w:+.0f} W by the {self.bound} bound
# (rating {self.limit_w:.0f} W) - something upstream is winding"
#
# Transpose `requested_w` and `applied_w` and the guard reports clamping a
# setpoint UPWARD - here, to +9999 W by a bound whose rating the SAME SENTENCE
# gives as 2000 W. MEASURED on this tree: a zero-byte edit (22 507 bytes before,
# 22 507 after, sha256 0153133741497fa5 -> 47e4e371043e3201), the emitted
# sentence changes, and the full 21-file suite stayed GREEN. Nothing decides on
# the order, so nothing behavioural can ever see it - which is why the check
# below has to be on the ORDERED PAIR and on the CONTIGUOUS LITERAL. A substring
# test for either number passes under the swap; both numbers are still there.
#
# ⚠️ AND THE OPERANDS ARE READ OFF THE PLANT, NOT OFF `clamp_last`. `ctl.target`
# is what the loop asked for and `hass.writes[-1]` is what the device was
# actually sent; neither is a `ClampResult` field. Grading the sentence against
# `result.requested_w` would compare the swap against itself - DEV-02's defect,
# and the same one that made the first cut of this card's own render probe emit
# thirteen empty diffs.
async def _sentence(max_w, dev, wanted):
"""One real write over `max_w`, then the guard, on a real Controller."""
hass = _Hass(0.0)
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
"max_w": max_w},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -dev, dev
c.target = wanted
arm(c, wanted)
await c.write_setpoint(wanted)
await c.check_output_clamp()
return hass, c
hass_x, ctl_x = _sentence_run = asyncio.run(_sentence(2000.0, 5000.0, 9999.0))
notes_x = clamp_notes(hass_x)
_asked_x = ctl_x.target # what the loop asked for
_sent_x = hass_x.writes[-1] # what the device was actually sent
_rating_x = ctl_x.inverters[0].max_w # the bound the sentence names
_rows_x = [e for e in ctl_x.events if REASON_CODE in e]
print(f" max_w {_rating_x:.0f} on a +-5000 W device, asked for "
f"{_asked_x:+.0f} W: the device saw {_sent_x:+.0f} W")
print(f" action log: {_rows_x[0] if _rows_x else '(none)'}")
check("PRECONDITION: the write really clamped on the MAX_W bound, and the two "
"operands are DIFFERENT NUMBERS that RENDER differently - equal ones "
"would make the transposed sentence identical and every check below "
"vacuous. The asked-for magnitude is also strictly ABOVE the rating "
"while the sent one is not, which is what makes the swapped sentence "
"self-contradictory rather than merely wrong",
ctl_x.clamp_last is not None and ctl_x.clamp_last.engaged
and ctl_x.clamp_last.bound == "max_w"
and _asked_x != _sent_x
and f"{_asked_x:+.0f}" != f"{_sent_x:+.0f}"
and abs(_sent_x) <= _rating_x < abs(_asked_x))
check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
"contiguous literal names the setpoint the loop COMPUTED first and the "
"one that REACHED THE WIRE second. Under the transposition this row says "
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
"past the 2000 W rating printed four words later - which is the alarm "
"reading backwards on the one line an operator is woken by",
any(f"asked {_asked_x:+.0f} W and sent {_sent_x:+.0f} W" in e
for e in _rows_x))
# UX-08: the notification is a SECOND surface off the same numbers, in its own
# words (messages.output_clamp): asked first, sent second.
def _asked_then_sent(msg, asked, sent):
return (f"asked for {messages.watts(asked)}," in msg
and f"and sent {messages.watts(sent)} instead" in msg
and msg.index(messages.watts(asked)) < msg.index("and sent"))
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surface "
"off the same numbers and has to be pinned separately - SAFETY-38 round "
"3's whole lesson was that pinning one call site leaves the other "
"shipping",
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
"checks above discriminate rather than decorate",
not any(f"asked {_sent_x:+.0f} W and sent {_asked_x:+.0f} W" in e
for e in _rows_x)
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
and present(nth(notes_x, 0)))
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
# unit of coverage is the call site, and `bound` is the only thing that differs
# between the two rows an operator can receive.
_asked_w, _sent_w = ctl_w.target, hass_w.writes[-1]
check("...and the same ordered pair holds on the DEVICE-bound row, where the "
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
"two also render differently",
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
and any(f"asked {_asked_w:+.0f} W and sent {_sent_w:+.0f} W" in e
for e in ctl_w.events)
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
# ==============================================================================
print()
print("7. SAFETY-28: the write path is gated on the allocation record")
# WHY THIS PART IS IN THIS FILE. Part 5 already claims "no path reaches the wire
# around the clamp", and the clamp is the last thing between a bug and the
# hardware. SAFETY-28 adds a second thing on the same path and asks a different
# question of it: not "is this number in range" but "WHO CHOSE THIS NUMBER".
#
# THE DEFECT IT REPLACES. specs §1.5 makes two writers on one actuator a
# non-goal, and until this ticket that was enforced by
# `len(validate.ACTUATOR_WRITE_KEYS) == 1` - one option key, so a second writer
# is not expressible. True, and a statement about the OPTIONS SCHEMA rather than
# about the writes: add CFG-04's per-inverter config and there are two real
# writers with that length still 1. test_validate.py part 2 grades the config
# half; this part grades the write half, which is the one that does not care
# what the options look like.
A_UNIT, B_HYPO = "inverter-a", "inverter-b"
def fresh(**over):
"""A Controller with a stub HA, no cycle run, and therefore NO record.
⚠️ SAFETY-10: `shadow_mode: "off"` for `_direct()`'s reason - this fixture
never presses Start, and the shipped default is shadow mode while control
is stopped. Part 7 grades who is allowed to reach the WIRE; a controller
that is correctly commanding nothing cannot answer that question.
"""
hass = _Hass(0.0)
c = Controller(dict({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off", "shadow_mode": "off"}, **over),
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
return hass, c
# --------------------------------------------------------------------------- #
# 7a. AC 1 - an unauthorised value is REFUSED AT THE WRITE PATH
# --------------------------------------------------------------------------- #
# The second writer, modelled as what it would actually look like in code: some
# other strategy with arithmetic of its own calling the one method that reaches
# the wire. It has a perfectly reasonable number - in range, finite, nothing the
# clamp would object to - and the only thing wrong with it is that nothing
# allocated it.
async def _second_writer():
hass, c = fresh()
arm(c, 1500.0) # the allocator placed 1500 W this tick
await c.write_setpoint(1500.0, force=True) # the loop's own write
await c.write_setpoint(1200.0, force=True) # ...and the second writer's
return hass, c
hass_2w, ctl_2w = asyncio.run(_second_writer())
print(f" allocator placed +1500 W; a second writer asked for +1200 W; "
f"the device saw {hass_2w.writes}")
check("AC 1: a value the allocation record does not contain never reaches the "
"device. 1200 W is in range and finite - the clamp has no opinion about "
"it - so the ONLY thing that stopped it is that no allocator placed it",
1200.0 not in hass_2w.writes)
check("...and it is REFUSED, not merely logged: 0 W goes on the wire in its "
"place. Returning instead would leave the inverter latched on the last "
"command with the ESP32 heartbeat still being fed - holding an "
"unauthorised setpoint quietly, which is the outcome this gate exists "
"to prevent",
hass_2w.writes == [1500.0, 0.0])
check("...and the action log says so in words an operator can act on",
any("WRITE REFUSED" in e for e in ctl_2w.events))
check("...while the authorised write of the SAME shape went through "
"untouched, so the gate is not simply refusing everything",
hass_2w.writes[0] == 1500.0)
# --------------------------------------------------------------------------- #
# 7b. AC 2 - a record from a previous tick cannot authorise a write in this one
# --------------------------------------------------------------------------- #
# ⚠️ THIS IS THE CACHED-ENVELOPE FAULT WEARING A DIFFERENT HAT. allocator.py's
# rule is that a unit's interval is read per tick and never cached, because a
# pack at its floor or a stale link moves it between one cycle and the next.
# A record is that envelope written down - so code that keeps one and replays it
# is caching the envelope through the record, and the allocator's own purity
# cannot stop it. The tick stamp is what stops it.
async def _replay():
hass, c = fresh()
stale = arm(c, 1500.0) # tick N
await c.write_setpoint(1500.0, force=True)
arm(c, 800.0) # tick N+1: the envelopes have moved on
c.allocation = stale # ...and somebody puts the old one back
await c.write_setpoint(1500.0, force=True)
return hass, c, stale
hass_rp, ctl_rp, stale_rec = asyncio.run(_replay())
print(f" a tick-{stale_rec.tick} record replayed on tick "
f"{ctl_rp.alloc_tick}: the device saw {hass_rp.writes}")
check("AC 2: replaying the previous tick's record does NOT authorise its "
"value again - the write is refused and 0 W goes out instead",
hass_rp.writes == [1500.0, 0.0])
check("...and the refusal names both ticks, so the reason is legible rather "
"than 'computer says no'",
"tick" in (ctl_rp.write_refusal(1500.0) or "")
and str(stale_rec.tick) in (ctl_rp.write_refusal(1500.0) or "")
and str(ctl_rp.alloc_tick) in (ctl_rp.write_refusal(1500.0) or ""))
check("...and an UNSTAMPED record - one built by a caller that passed no tick "
"at all - is refused too, because 'I cannot say when I was made' must "
"fail closed and not read as tick zero",
ctl_rp.allocation is not None
and (lambda c: (setattr(c, "allocation",
plan(1500.0, {A_UNIT: UnitBounds()}))
or c.write_refusal(1500.0) is not None))(ctl_rp))
# ⚠️ AND THE TICK HAS TO ADVANCE ON THE LIVE PATH, WHICH IS A DIFFERENT CLAIM
# FROM ANY OF THE ABOVE. Everything so far drives `arm()`, which bumps the
# counter itself - so all of it would stay green with `self.alloc_tick += 1`
# deleted from cycle(), and the replay protection would be gone on the only path
# that matters. This drives REAL cycles and asks the same question of them.
async def _live_staleness():
hass = _Hass(6000.0)
c = Controller({"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "step_w": 10, "stale_input_s": 15},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
await c.read_inputs()
await c.cycle()
first, first_v = c.allocation, c.target
await c.read_inputs()
await c.cycle()
# ...and now the HOLD: one missed poll, which must NOT age the record, or a
# single dropped meter reading would drop the site to 0 W.
held_before = c.alloc_tick
c.grid = None
c.inputs_bad_since = datetime.now(timezone.utc)
await c.cycle()
hass.writes.clear()
await c.write_setpoint(c.target, force=True) # the heartbeat's own call
return c, first, first_v, held_before, hass
ctl_ls, rec1, val1, held_before, hass_ls = asyncio.run(_live_staleness())
print(f" two real cycles: tick {rec1.tick} -> {ctl_ls.allocation.tick}; "
f"a held cycle left it at {ctl_ls.alloc_tick}")
check("PRECONDITION: two real cycles produced two records with two different "
"ticks and a value worth replaying",
rec1.tick != ctl_ls.allocation.tick and val1 != 0.0)
check("the tick advances on the LIVE path, so cycle 1's record does not "
"authorise a write on cycle 2. Everything above this line drives the "
"test's own `arm()` and would stay green with the increment deleted from "
"cycle() - this is the check that would not",
(lambda c: (setattr(c, "allocation", rec1)
or c.write_refusal(val1) is not None))(ctl_ls))
check("⚠️ AND A CYCLE THAT PRODUCED NO RESOLUTION DOES NOT AGE IT. The "
"'inputs missing, not yet stale' hold returns before the allocator runs, "
"precisely so one dropped meter reading does not zero the inverter, and "
"the heartbeat re-affirms self.target through this same gate while it "
"does. A tick incremented per LOOP ITERATION rather than per RESOLUTION "
"would refuse that heartbeat and turn the hold into the outage it exists "
"to prevent",
ctl_ls.alloc_tick == held_before and hass_ls.writes == [ctl_ls.target]
and ctl_ls.target != 0.0)
# --------------------------------------------------------------------------- #
# 7c. THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE
# --------------------------------------------------------------------------- #
# ⚠️ THE HAZARD THIS PART EXISTS FOR. `write_setpoint()` is also how layer 2
# commands 0 W: cycle()'s two refusal branches substitute a 0 W failsafe and
# write it like any other cycle, and shutdown() and the operator's stop button
# do the same. Every one of those runs while the allocator has REFUSED - `ok` is
# False and there are no shares - so a gate that asked the allocator's
# permission to command zero would block the safety write behind the very
# component whose failure the safety write answers. A gate that can do that is
# worse than the hole it closes.
#
# The resolution: 0 W is authorised unconditionally, and it is authorised on the
# VALUE rather than on a `failsafe=True` flag the caller asserts. The argument is
# at Controller.write_refusal(): the hazard §1.5 names is N-times loop gain from
# broadcasting one target to N inverters, and at S = 0 the broadcast and the
# correct allocation COINCIDE - a_i = 1 for every unit gives every unit 0 W,
# which is exactly its share. There is no over-command to make at zero.
_hostile = []
_h, _c = fresh()
_hostile.append(("no record at all", _c))
_h, _c = fresh()
_c.alloc_tick = 1
_c.allocation = plan(1500.0, {A_UNIT: UnitBounds()}, scope_errors=("mis-routed",),
tick=1)
_hostile.append(("the allocator REFUSED this tick", _c))
_h, _c = fresh()
_c.allocation = arm(_c, 1500.0)
_c.alloc_tick += 7 # ...and then seven ticks went by
_hostile.append(("only a stale record", _c))
for _why, _c in _hostile:
check(f"0 W is authorised with {_why}", _c.write_refusal(0.0) is None)
check(f"...and a NON-zero write is still refused with {_why}, so the zero "
f"exit is a floor and not an amnesty",
_c.write_refusal(1500.0) is not None)
async def _failsafe_wire():
"""The refusal path END TO END, through the real cycle().
⚠️ THE ROSTER HERE IS A HYPOTHESIS AND NOTHING IS MEASURED FROM IT.
INVERTER B DOES NOT EXIST. It is named only so `live_units()` can return
nothing and drive the allocator into REFUSE_EMPTY, which is the state
main.py's first refusal branch is written for and the one this check needs:
the allocator itself has failed, and layer 2 must still be able to command
zero.
"""
hass = _Hass(0.0)
class _Blind(Controller):
def live_units(self):
return ()
c = _Blind({"setpoint_entity": "number.sp", "meter_source": "off",
"max_w": LIVE_MAX_W, "soc_entity": "sensor.soc",
"batt_entity": "sensor.batt", "meter_entity": "sensor.grid"},
hass, _Store(), _Pub(), stop=asyncio.Event())
# ⚠️ DEV-02: THE ROSTER IS GROWN BY ADDING AN `Inverter`, NOT BY ASSIGNING
# `units`. `Controller.units` is now DERIVED from `Controller.inverters` and
# has no setter, because a settable roster is a site that claims two
# actuators while holding one writer - a half-driven site with nothing to
# say so. The object carries the writer, which is what makes this fixture a
# roster of two rather than a tuple of two names.
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W # while the roster is one
_b = Inverter(B_HYPO, setpoint_entity="number.sp_b",
soc_entity="sensor.soc", batt_entity="sensor.batt",
max_w=LIVE_MAX_W, store=c.store)
_b.dev_min, _b.dev_max = -LIVE_MAX_W, LIVE_MAX_W
_b.soc, _b.batt = 50.0, 0.0
c.inverters.append(_b)
c.auto = True
for _ in range(3):
await c.read_inputs()
await c.cycle()
return hass, c
hass_fs, ctl_fs = asyncio.run(_failsafe_wire())
print(f" allocator refused every tick "
f"({ctl_fs.allocation.refused!r}); the device saw {hass_fs.writes}")
check("the allocator having failed, the 0 W failsafe still REACHES THE WIRE - "
"this is the deadlock the gate must not have, driven through the real "
"cycle() and its real refusal branch rather than asserted",
not ctl_fs.allocation.ok and hass_fs.writes == [0.0, 0.0]
and ctl_fs.target == 0.0)
check("...and it was the ALLOCATOR's refusal that produced it, not the gate's "
"- if the gate were the thing zeroing this cycle the action log would "
"say WRITE REFUSED and the distinction between the two failures would "
"be lost to whoever has to debug it",
any("ARBITER: REFUSED" in e for e in ctl_fs.events)
and not any("WRITE REFUSED" in e for e in ctl_fs.events))
# --------------------------------------------------------------------------- #
# 7d. `unmet_w` IS NOT `ok`, AND THE GATE MUST NOT FUSE THEM
# --------------------------------------------------------------------------- #
# allocator.py says it plainly: an allocation that could not place every watt is
# still SAFE TO WRITE and strictly better than commanding zero. Fusing the two
# would let ONE SATURATED INVERTER PARK THE WHOLE SITE - the site target goes
# unmet by 3800 W, every share is legal, and a gate reading `unmet_w` would
# answer that by commanding 0 W and meeting even less of it.
_tight = plan(4000.0, {A_UNIT: UnitBounds(lo=-100.0, hi=100.0),
B_HYPO: UnitBounds(lo=-100.0, hi=100.0)}, tick=1)
_h, _c = fresh()
_c.alloc_tick, _c.allocation = 1, _tight
print(f" saturated site: {_tight.explain()}")
check("PRECONDITION: this record is ok AND has watts it could not place",
_tight.ok and _tight.unmet_w == 3800 and _tight.shares[A_UNIT] == 100)
check("the gate authorises this unit's share anyway. `unmet_w` is not read - "
"it is the site target that went unmet, not a broken allocation",
_c.write_refusal(100.0) is None)
check("...and it still refuses a value that is not the share, so 'do not read "
"unmet_w' has not been implemented as 'read nothing'",
_c.write_refusal(4000.0) is not None)
# --------------------------------------------------------------------------- #
# 7e. A RECORD THAT CONTRADICTS ITSELF AUTHORISES NOTHING
# --------------------------------------------------------------------------- #
# `UnitPlan.explain()` renders a share outside its own interval as
# "ENVELOPE VIOLATED, DO NOT WRITE". This is the DO NOT WRITE. The shape is the
# half-open clamp defect ALLOC-01 fixed: a plan stating the floor and a share
# below it in the same breath, and agreeing with itself.
#
# ⚠️ UNREACHABLE FROM THE ALLOCATOR AS IT STANDS, AND GRADED ANYWAY. At the
# shipped roster of one the pass-through's interval is (-inf, +inf) and every
# number is inside it. PR #40's reviewer proved what an unreachable ungraded
# branch is worth by replacing one with `if False:` and watching the whole suite
# stay green, so this one is exercised on a constructed record rather than
# trusted.
_liar = Allocation(target_w=500.0, sum_w=500, shares={A_UNIT: 500},
plans=(UnitPlan(A_UNIT, 500, 2000.0, math.inf, 1.0,
"weighted share"),), tick=1)
_h, _c = fresh()
_c.alloc_tick, _c.allocation = 1, _liar
print(f" {_liar.plans[0].explain()}")
check("PRECONDITION: the record is `ok`, and its share IS what the gate would "
"otherwise match on - so nothing but the envelope question stops it",
_liar.ok and _liar.shares[A_UNIT] == 500)
check("a share outside its own plan's interval is refused, and the refusal "
"quotes the plan's own DO NOT WRITE line",
"DO NOT WRITE" in (_c.write_refusal(500.0) or ""))
# --------------------------------------------------------------------------- #
# 7f. AC 5 - AT N=1 NOTHING CHANGES
# --------------------------------------------------------------------------- #
# The strongest form of this is not a new assertion, it is the 17 other files in
# this suite passing unedited on real cycles. What is added here is the
# statement that the live path is not being quietly zeroed: over part 3's
# sixty-cycle replay - the 10.3 runaway, an inverter that never follows - the
# gate refused nothing at all.
check("AC 5: across part 3's 60 real cycles the gate refused ZERO writes, so "
"the shipped one-inverter path reaches the wire exactly as it did "
"before. A gate that silently substituted 0 W here would leave every "
"clamp check above green and the install dead",
not [e for e in ctl_r.events if "WRITE REFUSED" in e])
check("...and the numbers those cycles put on the wire are the arbiter's own "
"target, unrounded and unaltered by the record it now has to appear in",
hass_r.writes and all(abs(w) <= LIVE_MAX_W for w in hass_r.writes))
# --------------------------------------------------------------------------- #
# 8. SAFETY-10 - SHADOW MODE: THE DECISION PATH RUNS AND NOTHING REACHES THE
# INVERTER
# --------------------------------------------------------------------------- #
# This part belongs in THIS file and not in a new one: shadow mode is a
# substitution inside `_write_unit()`, one line below SAFETY-03's clamp and two
# below SAFETY-28's gate, and the property it claims - "nothing reaches the
# inverter" - is the same property parts 5 and 7 already grade from the other
# side. The fake `set_number()` in this file IS the wire.
#
# ⚠️ EVERY CHECK BELOW IS GRADED ON THE WRITES OR ON THE RENDERED SURFACE, NOT
# ON `Controller.shadow`. A check that asserted the flag would pass on a build
# in which the flag is set and the substitution is deleted, which is precisely
# the build this section exists to refuse.
#
# ⚠️ AND EVERY ONE OF THEM RUNS THE SAME STIMULUS IN BOTH MODES. MUTATION.md's
# rule: a per-thing check that has never seen the things DISAGREE is a total.
# `_s10_live` and `_s10_shadow` are the same 6000 W import through the same
# `Controller.cycle()`; the only difference is one option.
print("\n8. SAFETY-10 - shadow mode\n")
def _s10(shadow_mode, *, auto=True, cycles=25, grid=6000.0, dev=None, **over):
"""The same wired run as part 3, with `shadow_mode` and the start button.
⚠️ `auto` IS A PARAMETER BECAUSE `shadow_mode: auto` READS IT. The three
values and the two button positions are six states and they are not all
the same state; the pure-function table over all six is in
`test_validate.py`, and 8f says why it is not here.
"""
async def go():
hass = _Hass(grid, track=True)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": shadow_mode},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
# `dev` narrows what the DEVICE says it will accept, which is the
# clamp's second stage - the only way to make the clamp engage on a
# target the loop has already bounded at `max_w`.
c.dev_min, c.dev_max = ((-LIVE_MAX_W, LIVE_MAX_W) if dev is None
else (-dev, dev))
# ⚠️ CAPTURED PER CYCLE, BECAUSE THE HEARTBEAT OVERWRITES IT.
# `clamp_last` is assigned by every write, and the heartbeat below
# writes `inv.target` - 0 W in shadow mode - so the clamp's verdict on
# the DECISION is gone by the end of the run. Looking for it afterwards
# would grade the refresh instead of the cycle.
c.clamp_seen = []
for n in range(cycles):
await c.read_inputs()
await c.cycle()
if c.clamp_last is not None:
c.clamp_seen.append(c.clamp_last)
# ⚠️ THE HEARTBEAT IS DRIVEN HERE AND NOT LEFT OUT, BECAUSE IT IS
# THE HALF THAT PROVES SHADOW MODE IS NOT SILENCE. `_write_unit()`
# skips an unchanged value unless `force` is set, so a cycles-only
# fixture sees exactly ONE 0 W write and could not tell "holds the
# inverter at 0 W" from "wrote 0 W once and went quiet" - and the
# second of those leaves a real install's ESP32 in a tripped
# stale-input watchdog for the whole run. `heartbeat_s=0` makes
# every tick due, which is the shipped path with its clock wound
# forward rather than a second implementation of it.
await c.send_heartbeats(float(n), 0.0)
return hass, c
return asyncio.run(go())
_live_h, _live_c = _s10("off")
_shad_h, _shad_c = _s10("on")
# --- 8a. THE PRECONDITION, WHICH IS THE HALF THAT MAKES THE REST MEAN ANYTHING
# MUTATION.md habit 4: a check whose DATA cannot reach the failure it names is
# decoration. "No non-zero value was written" is trivially true of a stimulus
# that would not have produced one, so the same stimulus is first shown to
# produce a large one with the option off.
check("PRECONDITION: with shadow_mode off this stimulus really does drive the "
f"wire - {len(_live_h.writes)} writes, "
f"{len([w for w in _live_h.writes if w != 0.0])} of them non-zero, "
f"largest {max((abs(w) for w in _live_h.writes), default=0):.0f} W",
len([w for w in _live_h.writes if w != 0.0]) >= 10
and max((abs(w) for w in _live_h.writes), default=0) > 1000.0
and 0.0 not in _live_h.writes)
# --- 8b. NOTHING THE DECISION PATH PRODUCED REACHES THE INVERTER -------------
check("AC 1: in shadow mode EVERY value that reached set_number() is 0 W. "
f"Distinct values written: {sorted(set(_shad_h.writes))}",
_shad_h.writes and set(_shad_h.writes) == {0.0})
# ⚠️ AND IT IS NOT SILENCE. The inverter holds its last command forever and the
# ESP32's stale-input watchdog needs a refresh; a shadow mode that stopped
# writing would leave a standing command up under a page saying nothing is
# commanded. A check that only asserted "no non-zero write" would pass on that
# build too - this is the half that separates them.
check("...and it WROTE that 0 W rather than going silent, which is what keeps "
f"the inverter from holding its last command ({len(_shad_h.writes)} "
"writes)",
len(_shad_h.writes) >= 2)
# --- 8c. THE DECISION PATH ACTUALLY RAN ------------------------------------
# "Nothing reached the inverter" is also true of a controller that decided
# nothing. Both halves, or the feature is an off switch with a long name.
check("the full decision path still ran: the loop reached a target and the "
f"arbiter resolved one ({_shad_c.loop_w!r} W, "
f"would have commanded {_shad_c.shadow_would_w!r} W)",
_shad_c.loop_w is not None and abs(_shad_c.loop_w) > 500.0
and _shad_c.shadow_would_w is not None
and abs(_shad_c.shadow_would_w) > 500.0)
check("...and the decision it recorded is a number a live controller would "
"have written - it is inside the rail and it is not the 0 W that went "
"on the wire",
_shad_c.shadow_would_w != 0.0
and abs(_shad_c.shadow_would_w) <= LIVE_MAX_W)
# --- 8d. THE CARRIED COMMAND TRACKS THE WIRE, WHICH IS WHAT MAKES THE EXIT
# SAFE
check("the carried site command is what is STANDING ON THE WIRE, not the "
f"decision ({_shad_c.target!r} W standing, "
f"{_shad_c.shadow_would_w!r} W decided)",
_shad_c.target == 0.0 and _shad_c.shadow_would_w != 0.0)
check("...and every unit's share is too, so SAFETY-42's delivery watchdog is "
"not handed a share nothing was told to produce",
all(i.target == 0.0 for i in _shad_c.inverters))
def _s10_exit(shadow_first):
"""Run twelve cycles, force shadow mode off, take ONE more cycle.
Answers with `inv.last_write` either side of that boundary - WHAT STANDS ON
THE WIRE, updated only on an accepted write, which is the number the
inverter is actually latched on. Reading `hass.writes` instead would be
blind to the converged case, where the controller correctly re-sends
nothing at all.
⚠️ THE COMPARISON IS AGAINST THE SAME RUN THAT NEVER ENTERED SHADOW MODE,
not against a constant. `slew_w` bounds a step from the standing command,
so "did it resume from the wire or from a fiction" is a question about a
DIFFERENCE, and a hard-coded expectation would be the number the code
produces compared with itself.
"""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "on" if shadow_first else "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
before = c.inverters[0].last_write
c.o["shadow_mode"] = "off"
await c.read_inputs()
await c.cycle()
# ⚠️ NOT `float()`. `last_write` is None until something is ACCEPTED,
# and a build in which shadow mode writes nothing at all would turn
# this fixture into a TypeError - which MUTATION.md's FCST-06 section
# is about: a traceback grades none of the checks after it AND reads
# like a survivor. It is handed back as None and named below instead.
return before, c.inverters[0].last_write, c
return asyncio.run(go())
_exit_before, _exit_after, _exit_c = _s10_exit(True)
_never_before, _never_after, _never_c = _s10_exit(False)
print(f" shadow run: standing {_exit_before!r} W -> {_exit_after!r} W on "
"the first live cycle")
print(f" never in shadow: standing {_never_before!r} W -> "
f"{_never_after!r} W on the same cycle")
check("SOMETHING REACHED THE WIRE IN SHADOW MODE. `last_write` is set only on "
"an ACCEPTED write, so None here is the build that stops writing "
"altogether and leaves the inverter latched on its last command - the "
"failure shadow mode must not be a new way to reach",
_exit_before is not None and _exit_after is not None
and _never_before is not None and _never_after is not None)
_exit_before = -1.0 if _exit_before is None else float(_exit_before)
_exit_after = -1.0 if _exit_after is None else float(_exit_after)
_never_before = 0.0 if _never_before is None else float(_never_before)
_never_after = 0.0 if _never_after is None else float(_never_after)
check("PRECONDITION: the two arrangements DISAGREE - the run that was never in "
"shadow mode is standing somewhere else entirely at that boundary, so "
"the comparison below is not a number against itself",
_never_before != _exit_before and abs(_never_before) > 1000.0)
check("PRECONDITION: and the shadow run really was standing at 0 W, so the "
"step below is measured from the wire and not from a coincidence",
_exit_before == 0.0)
check("AC 2, leaving: the first live command after shadow mode is one slew "
"step from the 0 W that was actually standing on the wire - NOT one step "
"from the decision shadow mode reached, which never existed at the "
f"battery. |{_exit_after:.0f}| <= slew 1000 W",
abs(_exit_after) <= 1000.0)
check("...and the run that was never in shadow mode is NOT inside that bound "
"at the same cycle, which is what says the bound is a real constraint "
"here and not a property of the stimulus",
abs(_never_after) > 1000.0)
def _s10_enter():
"""Live first, then shadow mode, on a battery standing somewhere else."""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "off"}, hass, _Store(), _Pub(),
stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
standing = hass.writes[-1]
c.o["shadow_mode"] = "on"
await c.read_inputs()
await c.cycle()
# ⚠️ `inverters[0].last_write`, NOT `hass.writes[-1]`. The question is
# WHAT THE INVERTER IS LATCHED ON after the first shadow cycle, and a
# build that writes NOTHING leaves it latched on the live value while
# `hass.writes[-1]` still reads that same number - the two look
# identical from the write list and are opposite at the battery.
return standing, float(c.inverters[0].last_write), c
return asyncio.run(go())
_stood, _first_shadow, _enter_c = _s10_enter()
print(f" standing when shadow mode came on: {_stood:.0f} W")
check("PRECONDITION: the battery was standing at a real, non-zero command when "
"shadow mode was switched on - otherwise 'it reached 0 W' says nothing",
abs(_stood) > 500.0)
check("AC 2, entering: after ONE shadow cycle the inverter is latched on 0 W, "
"not on what it was doing a cycle earlier. The inverter holds its last "
"command FOREVER, so a shadow mode that merely stopped writing would "
"leave that command standing under a page saying nothing is commanded "
f"({_stood:+.0f} W -> {_first_shadow:+.0f} W)",
_first_shadow == 0.0)
# --- 8e. THE OTHER GUARDS ON THE WRITE PATH STILL OBSERVE --------------------
# The substitution is the LAST thing before the wire, below SAFETY-28's gate and
# below SAFETY-03's clamp, so both still grade the number the decision path
# produced. A shadow run that could not say "it would have hit the rating here"
# is answering a different question from the one it is for.
_cl_h, _cl_c = _s10("on", cycles=12, grid=9000.0, dev=400.0)
_cl_req = [r.requested_w for r in _cl_c.clamp_seen]
print(f" decision {_cl_c.shadow_would_w!r} W; what the clamp was asked "
f"about at each cycle boundary: {sorted(set(_cl_req))}")
check("PRECONDITION: the decision is a real, non-zero number and the wire "
"carried only 0 W on this run, so the two are distinguishable",
_cl_c.shadow_would_w is not None and _cl_c.shadow_would_w != 0.0
and set(_cl_h.writes) == {0.0})
check("SAFETY-03 still OBSERVES in shadow mode: the clamp was asked about the "
"number THE DECISION PATH PRODUCED, not about the 0 W that went on the "
"wire. That is what puts the substitution BELOW the clamp, and it is "
"what lets a shadow run report that the rating would have been hit",
_cl_req and all(r == _cl_c.shadow_would_w for r in _cl_req))
check("...and the clamp's own alarm still ran on it - the winding-loop "
"engagement reached the action log on a run where nothing was commanded",
any("clamp" in e.lower() or "CLAMP" in e for e in _cl_c.events))
check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
"which is the same answer it gives live, so shadow mode has not been "
"implemented by making every write unauthorised",
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
# UX-09: what the over-rating alarm and its row SAY in each mode. The loop
# bounds the command before the write clamp here, so an over-rating is injected
# as the write path records one, and check_output_clamp() is run on it. Nothing
# reaches the wire in shadow mode, so neither text may claim a command was sent.
def _said(mode):
h, c = _s10(mode, cycles=2)
c.inverters[0].clamp_last = ClampResult(7412.0, 5000.0, 5000.0, True,
"max_w")
asyncio.run(c.check_output_clamp())
notes = [d["message"] for d in clamp_notes(h)
if d["notification_id"] == CLAMP_ID]
rows = [r["text"] for r in c.checks() if r.get("id") == "power_too_high"]
return notes, rows
_ux_live, _ux_shad = _said("off"), _said("on")
print(f" over-rating, live: {_ux_live}\n over-rating, shadow: {_ux_shad}")
check("UX-09: live, the over-rating alarm and row say what was SENT",
_ux_live[0] and all(" and sent 5 000 W" in m for m in _ux_live[0])
and _ux_live[1] and all("sent 5 000 W" in t and "would" not in t
for t in _ux_live[1]))
check("...in shadow mode they say what WOULD have been sent and that the "
"inverter stays at 0 W, never that it was sent",
_ux_shad[0] and all("would have sent 5 000 W" in m
and " and sent " not in m and "stays at 0 W" in m
for m in _ux_shad[0])
and _ux_shad[1] and all("would send 5 000 W" in t and " sent " not in t
for t in _ux_shad[1]))
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
# cycle, AFTER it. Re-seed one line earlier and the freeze detector grades a
# 0 W that no decision produced: its conjunction needs a command at the rail,
# so it goes permanently blind and the whole suite stays green. The detector's
# own `evaluate` is wrapped here because the arguments are the only place that
# ordering is observable.
_fz_seen = []
_fz_real = _shad_c.freeze_wd.evaluate
def _fz_spy(unchanged, target, *a, **k):
_fz_seen.append(target)
return _fz_real(unchanged, target, *a, **k)
_shad_c.freeze_wd.evaluate = _fz_spy
async def _one_more():
await _shad_c.read_inputs()
await _shad_c.cycle()
asyncio.run(_one_more())
_shad_c.freeze_wd.evaluate = _fz_real
print(f" SAFETY-18 was handed: {_fz_seen}")
check("PRECONDITION: the freeze detector was consulted at all on that cycle",
len(_fz_seen) == 1)
check("SAFETY-18 still OBSERVES the DECISION: it is handed the arbiter's "
"resolved intent, not the 0 W the wire gate substituted. Re-seeding one "
"line earlier would hand it a command no decision produced and its "
"conjunction - a command at the rail against a meter that has stopped "
"moving - could never be satisfied again",
_fz_seen and _fz_seen[0] == _shad_c.shadow_would_w
and _fz_seen[0] != shadow.WIRE_W)
# --- 8f. THE MODE TABLE LIVES IN test_validate.py ---------------------------
# `shadow.engaged()` is a pure function of the option and the start button, and
# the six states it answers for are graded beside the rule that validates the
# option - not here, where a defeat of the DEFAULT stops part 3's wired run
# reaching the wire at all and takes this whole file down before part 8 runs.
# ⚠️ THAT IS MEASURED, NOT ANTICIPATED: the mutation that makes `auto` resolve
# to shadow unconditionally aborts part 3 with an IndexError, so its red count
# here is a LOWER BOUND and none of part 8 is graded (MUTATION.md, FCST-06).
# --- 8g. THE OPERATOR STOP, WHICH IS THE ONE FAILSAFE THIS CARD CHANGES ------
_stop_live_h, _stop_live_c = _s10("off", auto=False, cycles=6)
_stop_shad_h, _stop_shad_c = _s10("auto", auto=False, cycles=6)
print(f" stopped, shadow off: {_stop_live_c.reason!r}")
print(f" stopped, shadow on : {_stop_shad_c.reason!r}")
check("PRECONDITION: with shadow mode off, a stopped controller files the "
"P_SAFETY stop claim and the arbiter resolves to THAT source, at 0 W",
_stop_live_c.reason.startswith("safety")
and _stop_live_c.target == 0.0)
check("in shadow mode that claim is NOT filed, so the arbiter resolves to the "
"REAL winner and the recorded decision is the real one. A P_SAFETY claim "
"out-ranks everything - left in, every shadow run would answer the wrong "
f"question ({_stop_shad_c.reason!r})",
_stop_shad_c.reason.startswith("loop")
and _stop_shad_c.shadow_would_w is not None
and abs(_stop_shad_c.shadow_would_w) > 0.0)
check("...and the actuator ends at exactly the same place either way, because "
"the substitution that replaced the claim is STRICTLY STRONGER: a claim "
"can be out-ranked and a wire gate cannot",
set(_stop_shad_h.writes) == {0.0} == set(_stop_live_h.writes))
# --- 8h. THE RENDERED SURFACE (TARIFF-11's pattern) -------------------------
# Graded on `status_payload()`, which IS the status page: web.py's `PAGE` is a
# static shell whose banner and rows are filled from this JSON. Named slots, not
# substring tests, so a transposition moves a slot.
_ROW = "Shadow mode"
_shad_pay = _shad_c.status_payload()
_live_pay = _live_c.status_payload()
_shad_rows = dict(_shad_pay["rows"])
_live_rows = dict(_live_pay["rows"])
print(f" banner: {_shad_pay['banner']!r}")
print(f" row : {_shad_rows.get(_ROW)!r}")
check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
"never how an operator learns which one they are in",
_ROW in _shad_rows and _ROW in _live_rows)
check("AC 3: the banner states plainly that the battery is not being "
"controlled",
"Shadow mode" in _shad_pay["banner"]
and "not controlled" in _shad_pay["banner"])
check("...and it is not green. A page that reads `ok` while nothing is being "
"commanded is the 'looks healthy but is inert' failure itself",
_shad_pay["level"] == "warn")
check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"]
and "Shadow mode" not in _live_pay["banner"])
_slot = re.search(r"This cycle it would ask for ([-+][\d ]+) W, the inverters "
r"stay at ([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them",
_slot is not None)
if _slot is not None:
_would = float(_slot.group(1).replace(" ", ""))
check("...and that number is the decision the path reached, not the 0 W "
f"standing on the wire ({_would:.0f} vs {_shad_c.target:.0f})",
_would == round(float(_shad_c.shadow_would_w))
and _would != _shad_c.target)
check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number",
float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the page's own Why line, the "
"arbiter's answer in plain words (UX-08), rather than a re-derivation",
_shad_c.why_words in _shad_rows[_ROW]
and dict(_shad_pay["rows"])["Why"] == _shad_c.why_words)
check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this "
"controller and not about the string",
_live_rows[_ROW].startswith("Off")
and "reaches the inverters" in _live_rows[_ROW]
and "would ask for" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to say what they would have said - a
# latch that is up while the controller is inert is up the moment it stops
# being inert. UX-09: the banner (12 words) says a safety stop outlasts shadow
# mode, and the latch's own red row, still on the list, names it.
_shad_c.inverters[0].sign_wd.tripped = True
_latched = _shad_c.status_payload()
print(f" banner with a latch up: {_latched['banner']!r}")
check("a failsafe latch that is standing turns the shadow banner red and the "
"banner says a safety stop stays on, so nobody turns shadow mode off "
"into a battery that will not move and a page that never mentioned why",
_latched["level"] == "bad"
and "safety stop stays on" in _latched["banner"])
check("...and the latch is NAMED on its own red row, which says how it clears",
any(not r["ok"] and not r["warn"] and r.get("id") == "direction"
and "A restart does not clear this" in r["text"]
for r in _shad_c.checks()))
check("...and it still says the battery is not being controlled, so the latch "
"has not displaced the sentence that may never be displaced",
"not controlled" in _latched["banner"])
_shad_c.inverters[0].sign_wd.tripped = False
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
# A shadow run is only worth running if it answers "what would it have done,
# and why" AFTERWARDS, from the 40-row action log, and not only on a page
# somebody happened to be looking at.
_shadow_lines = [e for e in _shad_c.events if "SHADOW" in e]
print(f" action log, shadow lines: {len(_shadow_lines)} of "
f"{len(_shad_c.events)}")
check("the action log records that shadow mode came on, in words",
any("SHADOW MODE ON" in e for e in _shadow_lines))
check("...and what it would have commanded, with the arbiter's reason beside "
"it",
any("would have commanded" in e and "loop" in e for e in _shadow_lines))
check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
"log that has to carry the context of what the run found",
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
# ==============================================================================
print()
print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
"are the clamp tick's own")
# Three defects, each graded on the wired Controller.cycle() with compute()
# spied, so every number is compared with the Decision of the SAME tick:
# 1. `i_clamped` (normal anti-windup) used the over-rating BUG SIGNAL text;
# 2. "the loop asked for" printed `Decision.i_w`, the integrator, as a request;
# 3. "was clamped to" printed `self.target`, read after slew/freeze rewrote it.
# The fixtures are chosen so the numbers the old text used DIFFER from the true
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
import logging
import app.main as _main_mod
_real_compute = _main_mod.compute
class _LogCap(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
def s40_run(grid, cycles, track, **over):
"""Per tick: (Decision, self.target, clamp_wd.reason, engaged) + hass + log."""
ticks, decisions = [], []
def spy(*a, **k):
d = _real_compute(*a, **k)
decisions.append(d)
return d
cap = _LogCap()
lg = logging.getLogger("goodwe")
lg.addHandler(cap)
_main_mod.compute = spy
async def go():
hass = _Hass(grid, track=track)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
red = []
for _ in range(cycles):
n = len(decisions)
before = c.clamp_wd.engagements
await c.read_inputs()
await c.cycle()
d = decisions[-1] if len(decisions) > n else None
ticks.append((d, c.target, c.clamp_wd.reason,
c.clamp_wd.engagements > before))
if c.clamp_wd.active and not red:
red = c.checks()
return hass, c, red
try:
hass, c, red = asyncio.run(go())
finally:
_main_mod.compute = _real_compute
lg.removeHandler(cap)
return ticks, hass, c, red, cap.records
def _f(d, name):
"""A Decision field, NaN when absent - so an old tree goes red, not crashes."""
return getattr(d, name, float("nan"))
# --- 9a. default install: only the INTEGRATOR bound can engage ---------------
ticks_i, hass_i, ctl_i, red_i, log_i = s40_run(6000.0, 20, track=False)
wind = [(d, t, r) for d, t, r, e in ticks_i
if e and d is not None and d.i_clamped and not d.clamped]
for d, t, r in wind:
print(f" tick: bound {_f(d, 'i_bound_w'):+.0f} i_w {d.i_w:+.0f} command "
f"{d.target_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the integrator bound engaged, and on a tick where the "
"carried integrator AND the emitted command both differ from the bound",
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
for d, t, r in wind))
check("defect 1: the integrator bound is NOT reported as an over-rating",
wind and all(" rating" not in r and "fault" not in r
for d, t, r in wind))
check("defect 1: ...it names the integrator's bound, from the same tick",
wind and all(f"reached its {_f(d, 'i_bound_w'):+.0f} W limit" in r
for d, t, r in wind))
check("defect 2: the integrator accumulator is not printed as a request",
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
for d, t, r in wind))
check("the command quoted is that tick's Decision, not a later read",
wind and all(f"command now {d.target_w:+.0f} W" in r
for d, t, r in wind))
check("at the bound nothing is described as 'above' or 'over' the rating",
wind and all(" above " not in r and " over " not in r
for d, t, r in wind))
notes_i = clamp_notes(hass_i)
check("it still NOTIFIES (not suppressed), under a title that claims no bug",
notes_i and all(n["title"] == messages.T_LOOP_BOUND
and "fault" not in n["message"] for n in notes_i))
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
"notice still on screen",
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
# UX-11: the WARNING quotes the code. The reason is on the DEBUG line beside
# it (test_messages.py part 6).
check("...logged as a WARNING, not an ERROR",
any(x.levelno == logging.WARNING
and x.getMessage().startswith("The battery gives less than asked: "
"OUTPUT_CLAMP.") for x in log_i)
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
for x in log_i))
check("...and the status row is amber, not red",
any(c["warn"] and c.get("id") == "not_keeping_up" for c in red_i)
and not any(not c["warn"] and c.get("id") == "power_too_high"
for c in red_i))
# --- 9b. the genuine over-rating: integrator_max_w above max_w ----------------
ticks_o, hass_o, ctl_o, red_o, log_o = s40_run(9000.0, 12, track=True,
integrator_max_w=9000)
over = [(d, t, r) for d, t, r, e in ticks_o
if e and d is not None and d.clamped]
for d, t, r in over[:2]:
print(f" tick: requested {_f(d, 'requested_w'):+.0f} clamped {_f(d, 'clamped_w'):+.0f}"
f" i_w {d.i_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
"rewrote the command AND the integrator differs from the request",
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
check("the over-rating says it was cut to the rating",
over and all(" W rating, command now " in r for d, t, r in over))
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
check("defect 3: 'cut to' is the clamp's own result, not self.target",
over and all(f"cut to the {_f(d, 'clamped_w'):+.0f} W rating" in r
for d, t, r in over))
check("...and its ERROR says it is a software fault and that control keeps "
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
"it, which is still in main.py)",
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
and "control runs on" in x.getMessage() for x in log_o)
and "reached 14 768 W on this project" in
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
"main.py"), encoding="utf-8").read())
check("...under the over-rating title, quoting a tick's own asked and sent "
"numbers in that order, with a red status row",
clamp_notes(hass_o)
and all(n["title"] == messages.T_OUTPUT_CLAMP
and n["notification_id"] == CLAMP_ID
and any(_asked_then_sent(n["message"], _f(d, 'requested_w'),
_f(d, 'clamped_w')) for d, t, r in over)
for n in clamp_notes(hass_o))
and any(not c["warn"] and c.get("id") == "power_too_high"
and c["text"].startswith(messages.T_OUTPUT_CLAMP)
for c in red_o))
# The red row quotes the same tick's numbers as the card, asked before sent.
_row_o = [c["text"] for c in red_o if c.get("id") == "power_too_high"]
check("...and the red row quotes asked before sent, from one of those ticks",
_row_o and any(
f"asked {messages.watts(_f(d, 'requested_w'))}" in _row_o[0]
and f"sent {messages.watts(_f(d, 'clamped_w'))}" in _row_o[0]
and _row_o[0].index("asked") < _row_o[0].index("sent")
for d, t, r in over))
# --- 9c. the text, directly, and the two budgets -------------------------------
from app.clamp import loop_clamp_note # noqa: E402
d_at = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6), i_w=4500.0)
check(f"exactly at the rating ({d_at.i_bound_w:+.0f} W) is 'reached', never "
f"'above' - {loop_clamp_note(d_at)!r}",
d_at.i_clamped and not d_at.clamped and abs(d_at.i_bound_w) == 5000.0
and "above" not in loop_clamp_note(d_at))
check("a healthy decision produces no text",
loop_clamp_note(compute(0.0, 100.0, 0.0, Tuning(max_w=5000.0))) == "")
# Anti-windup notices must never spend the budget a BUG SIGNAL needs.
wd = ClampWatchdog()
fired_w = wd.note(True, "wind", T0, bug=False)
fired_b = wd.note(True, "bug", T0 + timedelta(seconds=5), bug=True)
wd.note(True, "wind again", T0 + timedelta(seconds=10), bug=False)
check("an anti-windup notice does not delay a bug in the same episode, and "
"does not overwrite its text",
fired_w and fired_b and wd.bug and wd.reason == "bug")
wd2 = ClampWatchdog()
seq = [wd2.note(True, "b", T0 + timedelta(seconds=5 * i)) for i in range(40)]
check("bug-only cadence is unchanged: rising edge, then every REASSERT_S",
seq[0] and sum(seq) == 1 + int(5 * 39 // REASSERT_S))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")