Files
goodwe-addon/goodwe_controller/test_loop.py
T
2026-09-01 03:38:00 +02:00

221 lines
9.3 KiB
Python

"""Runnable check for the control loop's cadence. `python3 test_loop.py`
SAFETY-12: the loop must evaluate on a clock as well as on a change. Before the
fix, `run_control()` ran `cycle()` only when the meter value differed from the
previous poll, so a meter that repeats a value ran NO control cycles while the
heartbeat kept re-emitting the last command - measured on the rig during TEL-05
as 70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
⚠️ THIS TEST TAKES ~95 SECONDS OF WALL CLOCK (measured 93 s), on purpose, and
that figure is the only thing standing between it and a future developer who
decides a slow test is a broken one. The thing under test is a wall-clock floor
inside a loop that sleeps 1 s; mocking the clock would leave the assertion
testing the mock. It drives the real `run_control()` and counts what actually
happened. Eight runs of the real loop is what the eight scenarios cost.
⚠️ If you shorten the windows, re-measure and update this number and the one in
HANDOVER.md. An understated runtime reads as a hang.
`cycle_floor_s` is read from options with no lower bound in code, so a huge
value reproduces the pre-fix behaviour exactly. Every claim below is therefore
an A/B on one binary, not a comparison against a remembered old one.
⚠️ 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 sys
import time
from app.main import Controller
fails = []
NEVER = 1e9 # cycle_floor_s so large the floor can never elapse: pre-fix
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class FakeHass:
"""A scripted meter and a plant that tracks the command instantly."""
def __init__(self, meter, soc_dies_at=None):
self.meter = meter
self.soc_dies_at = soc_dies_at
self.batt = 0.0
self.writes = []
self.t0 = time.monotonic()
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
now = time.monotonic() - self.t0
if entity == "sensor.grid":
return self.meter(now)
if entity == "sensor.batt":
return self.batt
if entity == "sensor.soc":
if self.soc_dies_at is not None and now >= self.soc_dies_at:
return None
return 50.0
return None
async def set_number(self, entity, value):
self.writes.append(float(value))
self.batt = float(value)
return True
class _Store:
data = {}
def set(self, *a):
pass
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15}
def drive(meter, floor, seconds, soc_dies_at=None, **over):
"""Run the real control loop for `seconds`; return (cycle times, writes)."""
async def go():
hass = FakeHass(meter, soc_dies_at)
c = Controller(dict(BASE, cycle_floor_s=floor, **over), hass, _Store(),
_Pub(), stop=asyncio.Event())
c.auto = True
stamps = []
real = c.cycle
async def counted():
stamps.append(time.monotonic() - hass.t0)
await real()
c.cycle = counted
task = asyncio.create_task(c.run_control())
await asyncio.sleep(seconds)
c.stopping = True
await asyncio.wait_for(task, timeout=10)
return stamps, hass.writes, c.target, c.reason
return asyncio.run(go())
def worst_gap(stamps, seconds):
"""Longest stretch with no cycle, including the tail after the last one."""
edges = [0.0] + list(stamps) + [seconds]
return max(b - a for a, b in zip(edges, edges[1:]))
# --------------------------------------------------------------------------- #
print("a meter that repeats the same value")
FLAT = lambda t: 350.0 # noqa: E731
FLOOR, SECONDS = 3.0, 12.0
pre_stamps, pre_writes, _, _ = drive(FLAT, NEVER, SECONDS)
# The defect itself, demonstrated rather than described: one cycle at startup
# and then nothing for the rest of the window, while writes keep happening.
check("pre-fix: a constant meter runs exactly one cycle, at startup",
len(pre_stamps) == 1)
check("...while the heartbeat keeps writing anyway, so nothing looks wrong",
len(pre_writes) >= 2)
post_stamps, post_writes, _, _ = drive(FLAT, FLOOR, SECONDS)
check("post-fix: the same constant meter runs cycles throughout",
len(post_stamps) >= int(SECONDS / FLOOR) - 1)
# The actual acceptance criterion: a BOUND on the gap, not a count.
gap = worst_gap(post_stamps, SECONDS)
check(f"...with no gap longer than the floor ({gap:.1f}s <= {FLOOR + 1.5:.1f}s)",
gap <= FLOOR + 1.5)
check("...and the loop actually corrected, instead of holding a stale command",
len(post_writes) > len(pre_writes))
# --------------------------------------------------------------------------- #
print("a meter that changes normally - nothing may move")
# ⚠️ THE REGRESSION GUARD, and the more important half. The floor is a deadline
# on the gap between cycles, reset by every cycle whichever trigger fired - not
# a second scheduler. On a healthy meter it must never elapse, so the cycle
# count, the write count and the commanded values must be IDENTICAL either side
# of the fix. Anything else is a new write cadence, which SAFETY-12 forbids:
# the deadband, step_w and the 10 s force-write decide what reaches the wire,
# and this change is only allowed to alter when the loop thinks.
STEPS = [0.0, 300.0, 850.0, -400.0, 120.0, -900.0, 60.0]
LIVE = lambda t: STEPS[int(t // 2) % len(STEPS)] # noqa: E731
a_stamps, a_writes, _, _ = drive(LIVE, NEVER, 12.0)
b_stamps, b_writes, _, _ = drive(LIVE, FLOOR, 12.0)
check("a changing meter runs the same number of cycles as before the fix",
len(a_stamps) == len(b_stamps))
check("...commanding exactly the same sequence of values", a_writes == b_writes)
# --------------------------------------------------------------------------- #
print("a meter parked at the target - the converged operating point")
# The case TEL-05 warned about: at the converged target the power figure
# legitimately repeats for minutes. The loop must still run, and must still
# write nothing but the heartbeat, because the deadband - not the arrival
# trigger - is what holds the command still.
AT_TARGET = lambda t: -10.0 # noqa: E731
c_stamps, c_writes, _, _ = drive(AT_TARGET, NEVER, 12.0)
d_stamps, d_writes, _, _ = drive(AT_TARGET, FLOOR, 12.0)
check("the loop evaluates at the converged point too", len(d_stamps) > len(c_stamps))
check("...and the deadband still holds the command, so writes are unchanged",
d_writes == c_writes)
# --------------------------------------------------------------------------- #
print("an input dies while the meter repeats - the safety claim must still fire")
# ⚠️ THE STRONGEST PROPERTY IN SAFETY-12, and the one that makes it a safety fix
# rather than a latency fix. The `inputs missing -> 0 W` claim lives INSIDE
# cycle() (main.py, the stale_input_s branch and the P_SAFETY claim below it).
# Pre-fix, on a meter whose value happened to repeat, cycle() was never called
# again - so that claim could not fire AT ALL. The controller held its last
# command through an input outage of unbounded length with the status line still
# reading "loop -> N W".
#
# Layer 1 does not cover this either on a `meter_source: off` install: no P1 age
# sensor is published (see Controller.publish), so the firmware has nothing to
# veto on, and the heartbeat keeps its watchdog fed the whole time.
#
# stale_input_s is shortened to 3 s here so the outage resolves inside a test
# rather than inside a quarter of a minute. The mechanism is the default one.
c2_stamps, c2_writes, pre_target, pre_reason = drive(
FLAT, NEVER, 10.0, soc_dies_at=4.0, stale_input_s=3)
check("pre-fix: a dead input during a constant meter never reaches the safety claim",
pre_target != 0.0 and "safety" not in pre_reason)
check(f"...so it holds its last command instead ({pre_target:g} W, {pre_reason!r})",
pre_target == 220.0)
d2_stamps, d2_writes, post_target, post_reason = drive(
FLAT, FLOOR, 10.0, soc_dies_at=4.0, stale_input_s=3)
check("post-fix: the same outage commands 0 W", post_target == 0.0)
check(f"...through the safety claim, not by accident ({post_reason!r})",
"safety" in post_reason)
check("...and the 0 W actually reached the device", 0.0 in d2_writes)
# --------------------------------------------------------------------------- #
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")