Files
goodwe-addon/goodwe_controller/test_control.py
T
adminandClaude Opus 5 6c4565659b
checks / unit (pull_request) Successful in 5m30s
SAFETY-13: the output freeze gets an exit, and only where it needs one
While the loop is frozen the command's magnitude may fall but never rise. At
prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That
refusal is deliberate and it is kept: the inverter reporting power we did not
command means another writer is on the actuator, which is the §1.5 non-goal.
What it did not have was an END - nothing cleared it but the other writer
leaving.

Reproduced on release/1.0 @ c8dcb38, at the live install's shape (max_w 5000,
slew_w 400, target_grid_w -10), with another writer holding the pack at +700 W.
The route is not the obvious one: entering from an export, the slew limit moves
the command off zero before the counter reaches 3. It is entered from the
DEADBAND - a site resting at its target counts saturated cycles while nothing is
wrong - and 0 W is then a fixed point: a 2 kW export arriving afterwards leaves
the command at exactly 0.0 W on every subsequent cycle.

And prev_w == 0 is not where it stops. The same tuning pins at -800 W against
its own integrator standing at -2388 W, with the same absence of an exit. So the
change is written against the output freeze, not against a special case of it.

THE DECISION IS WHICH FREEZES GET AN EXIT, NOT HOW LONG THE BLOCK LASTS.

  - A divergence a LIMIT could have produced - the inverter doing less than the
    command, in the command's own direction - clears itself, because the command
    is pinned at something the inverter can reach. No exit. release/1.0
    unchanged, and asserted so: a curtailed inverter is never released over 30
    cycles.
  - A divergence a limit could NOT have produced - overshooting the command, or
    the opposite sign - cannot clear itself, because clearing it needs the
    inverter to track OUR command. That one releases.

unasked_power() is that predicate and it is physical, not statistical: a limit
can only make the inverter do less, in the command's own direction, so
everything a limit can produce lies in the interval spanned by 0 and prev_w
widened by saturation_w. At prev_w == 0 it IS the saturation test, which is
exactly why the zero case is the one that strands.

The bound is not a new number. SETTLE_CYCLES = ceil(PLANT_SETTLE_S /
LOOP_TICK_S) = 9, SAFETY-39's two measured constants and the reciprocal of
SLEW_RAIL_FRAC: a divergence still standing after the plant's own settling time
is not this plant catching up. _SAT_COUNT_CAP is written as SETTLE_CYCLES + 1;
its value is unchanged at 10.

RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP, which is what makes this
bounded. The integrator freeze is untouched, so a released cycle can only move
the command toward an accumulator that stopped accumulating, by at most one slew
step, and never past it - swept over 107 625 released states. On the run above
the command starts on the ninth saturated cycle and settles at -1190 W against a
frozen integrator of -1194 W, not at the 5000 W rail.

Graded against a transcription of release/1.0 over the 3024-case sweep, which
was NOT taught the new rule: 2592 cases bit-identical, 432 releases, and 27 of
those change the command - every one a charge from prev_w == 0 that release/1.0
refused to start.

SAFETY-04's deliberate assertion is kept rather than inverted: its fixture sits
below the bound, so it still asserts the block, and the release is asserted
beside it on the other side of the bound.

Coverage audit re-measured, same mutation form, __pycache__ purged: output
freeze 2 -> 11 (2 reproduced on a pristine c8dcb38 first), the exit 4,
unasked_power 6.

saturation_cycles at 9 or 10 removes the block entirely, because the freeze
cannot engage before its own exit is due. Not refused at startup; asserted in
the tests and written in DOCS.md.

Nothing was measured on two inverters: inverter B does not physically exist
(NOTES.md §1.1b) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with
measured=False. The N=2 escalation is stated from the code (actual_w is
site_batt_w, the SUM) and is labelled as such.

22/22 test files pass in goodwe-addon-ci, mounted :ro, bytecode purged first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 13:56:26 +02:00

925 lines
51 KiB
Python

"""Runnable check for the control law. `python3 test_control.py`
No framework, no fixtures - it needs to run on a tech's laptop and in CI with
nothing installed. Every assert here corresponds to a rule that exists because
its absence caused an observed failure on real hardware.
If you change control.py, run this. If it fails, the inverter would have done
something you did not intend.
⚠️ 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 sys
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SETTLE_CYCLES,
SLEW_RAIL_FRAC, Tuning, compute, slew_limit_w,
unasked_power,
maintenance_charge_floor, peak_at_risk)
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
# `Tuning()`'s default became 0 - "follow the rail", `SLEW_RAIL_FRAC * max_w`,
# 222 W against this 2000 W rail - so a bare `Tuning()` here would silently
# re-tune every assertion below that the slew limit touches, and several of them
# assert on a number the slew produces. The pair written here (max_w 2000,
# slew_w 1000) is exactly what release/1.0 shipped, which is what most of this
# file is a regression test against. The new default gets its own section at the
# end, where it is the subject rather than a hidden parameter.
T = Tuning(slew_w=1000.0)
fails = []
def check(name, cond):
if cond:
print(f" ok {name}")
else:
print(f" FAIL {name}")
fails.append(name)
# ---------------------------------------------------------------------------
# COVERAGE AUDIT - measured, not executed. Read this before adding a mechanism.
#
# THE INVARIANT: every mechanism in compute() must be noticed by AT LEAST TWO
# checks when it is deleted. If you add a mechanism to compute(), re-run the
# audit and add it to the table. If a figure here drops, a check has started
# passing for a reason other than the one it names.
#
# THE TECHNIQUE, because there is no script to run: replace one mechanism in
# control.py with a no-op, run this file, count the failures, restore. That is
# the converse of the usual mutation - not "does a wrong value fail?" but "does
# anyone notice when the mechanism is GONE?". It is kept as a comment rather
# than as tooling on purpose: the only cheap way to automate it is to key on
# source lines, which goes stale silently, and a green audit that has quietly
# stopped testing anything is precisely the failure this ticket exists to fix.
# A comment cannot go stale-green, because it never claims to be running.
#
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
# ⚠️ THE OUTPUT FREEZE ROW WAS RE-MEASURED AT SAFETY-13 AND IT MOVED FROM 2 TO
# 11, because the SAFETY-13 section grades that branch too. Both figures are
# from the SAME mutation form - the whole `target = min(...)/max(...)` line
# removed, `reason` left alone - and 2 was reproduced on a pristine c8dcb38
# tree before the new figure was taken, so the row moved because checks were
# added and not because the mutation changed shape. The two new rows below it
# are that section's own mechanisms: removing the EXIT restores release/1.0's
# unbounded freeze, and making `unasked_power()` return True gives an exit to
# the limit case as well, which is the half of the decision that has to stay
# unbounded.
#
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only
# removes the 0-means-the-rail resolution. Three checks notice, which is over
# the invariant's floor of two. Both figures were reproduced by deleting the
# mechanism, running this file and counting, on a tree with __pycache__ purged.
#
# mechanism in compute() checks that fail when deleted
# ------------------------------------------ -----------------------------
# integrator freeze (AC 3) 2
# integrator clamp (AC 1) 6
# integrator bound follows max_w 4
# output clamp 3
# slew limit 5
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
# output freeze 11
# the output freeze's EXIT (SAFETY-13), freeze made unconditional again 4
# `unasked_power()` discriminates, replaced by `return True` 6
# deadband 5
# quantisation 2
# saturation detector, `saturated_now = False` 11
# saturation duration (AC 2), fires instantly 2
# sat counter reset on a good cycle 6
# target_grid_w bias 3
# i_w=None seeding from prev_w 6
#
# The detector figure is for the `saturated_now = False` form specifically;
# disabling it further down as `frozen = False` is a weaker mutation and gives
# 10. Reproduce the same form or the number will not match.
#
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
# for the mechanism under test. Deleting the integrator freeze once failed
# NOTHING, because the fixtures sat at max_w 2000 and the integrator bound
# truncated a wound value back to exactly 2000 - the assertion passed on the
# clamp. The output clamp was masked the same way by the integrator bound.
# Hence: A FIXTURE MUST SIT CLEAR OF EVERY RAIL IT IS NOT TESTING. Where a test
# names one mechanism, make that mechanism the binding one (see TCLAMP and TF).
#
# ⚠️ RUN MUTATIONS THROUGH `tools/mutation_run.sh`, WHICH CLEARS __pycache__
# BEFORE IT RUNS ANYTHING. CPython invalidates a .pyc on (source mtime in whole
# seconds, source size), so a same-second rewrite that also preserves the file
# size reuses stale bytecode and the suite reports on code you are no longer
# running. It under-reported one mutation here as 2 where the true figure is 6.
#
# ⚠️ AND THE CLEAR IS THE HALF THAT WORKS. This note used to lead with
# `python -B`; TEST-15 measured what that flag actually does, and it is
# write-only - it does not stop an EXISTING stale .pyc being read, and a
# mutation run always has one, because the previous run wrote it. -B is still
# worth passing, for the NEXT edit rather than this one.
#
# The error is one-directional - stale bytecode can only under-report - so
# every figure above is a lower bound at worst, and the two zeros ever recorded
# were both confirmed by fixing them and watching the count rise, which a
# caching artefact cannot do.
# ---------------------------------------------------------------------------
print("control law")
# Deadband: inside meter noise, hold exactly - do not drift.
d = compute(prev_w=900, grid_w=10, actual_w=900, tuning=T)
check("deadband holds the command", d.target_w == 900 and d.reason == "deadband")
d = compute(prev_w=900, grid_w=20, actual_w=900, tuning=T)
check("outside deadband it acts", d.target_w != 900)
# Proportional: 0 + 0.6*500 = 300
d = compute(prev_w=0, grid_w=500, actual_w=0, tuning=T)
check("proportional step (gain 0.6)", d.target_w == 300)
# Sign: exporting (negative grid) must CHARGE (negative target).
d = compute(prev_w=0, grid_w=-500, actual_w=0, tuning=T)
check("export drives charging", d.target_w == -300)
# Clamp.
# ⚠️ integrator_max_w is lifted clear of max_w so that the OUTPUT clamp is the
# mechanism under test. Left at the default the integrator bound truncates
# first, these two assertions pass on that alone, and deleting the output clamp
# fails nothing - the same masking that hid the integrator freeze.
TCLAMP = Tuning(max_w=2000, slew_w=5000, integrator_max_w=5000)
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=TCLAMP)
check("clamped to max_w", d.target_w == 2000)
# Slew: from 0 with a huge error, no more than slew_w in one cycle.
d = compute(prev_w=0, grid_w=5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
check("slew limits one cycle", d.target_w == 1000)
d = compute(prev_w=-1900, grid_w=-1000, actual_w=-1900, tuning=TCLAMP)
check("clamped to -max_w", d.target_w == -2000)
d = compute(prev_w=0, grid_w=-5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
check("slew limits one cycle, charging", d.target_w == -1000)
# Saturation needs DURATION: one diverging cycle must NOT freeze.
t = Tuning(saturation_w=500, saturation_cycles=3)
d1 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=0)
check("one saturated cycle does not freeze", not d1.frozen and d1.sat_count == 1)
d2 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=d1.sat_count)
d3 = compute(prev_w=2000, grid_w=500, actual_w=1000, tuning=t, sat_count=d2.sat_count)
check("three consecutive saturated cycles freeze", d3.frozen)
check("freeze forbids raising magnitude", d3.target_w <= 2000)
# ...and one good cycle clears the counter immediately.
d4 = compute(prev_w=2000, grid_w=500, actual_w=1990, tuning=t, sat_count=3)
check("counter resets when tracking resumes", d4.sat_count == 0 and not d4.frozen)
# Freeze must still allow the magnitude to FALL (that is the escape route).
d = compute(prev_w=2000, grid_w=-800, actual_w=1000, tuning=t, sat_count=3)
check("freeze still allows magnitude to fall", d.target_w < 2000)
# Maintenance shaping (charge-only, cheap-window floor) is no longer this
# function's business - it is expressed as limit claims. See test_arbiter.py.
# Quantisation
d = compute(prev_w=0, grid_w=7, actual_w=0, tuning=Tuning(deadband_w=1, step_w=10))
check("quantised to step_w", d.target_w % 10 == 0)
print("SAFETY-04: the integrator is bounded apart from the output")
# The historical runaway, with its real numbers. A commercial controller on
# this site, with the inverter switched OFF, wound ~130 W every 4 s past 10 kW
# and reported 14 768 W while its output clamp sat at 5 kW. At gain 0.6 that
# rate is a standing error of 130/0.6 = 217 W that never resolves, because the
# inverter is not there to resolve it. 150 cycles is past the ~113 it took to
# reach 14 768 W at that rate.
RUNAWAY_ERROR = 130.0 / 0.6
RUNAWAY_CYCLES = 150
HISTORICAL_W = 14768.0
def runaway(tuning, sign=1):
"""Inverter off: it reports 0 W forever, the error never clears."""
prev, i_w, sat = 0.0, 0.0, 0
worst_i, worst_cmd = 0.0, 0.0
for _ in range(RUNAWAY_CYCLES):
d = compute(prev_w=prev, grid_w=sign * RUNAWAY_ERROR, actual_w=0.0,
tuning=tuning, sat_count=sat, i_w=i_w)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
worst_i = max(worst_i, abs(i_w))
worst_cmd = max(worst_cmd, abs(prev))
return worst_i, worst_cmd
TR = Tuning(max_w=2000) # integrator_max_w unset => follows max_w
wi, wc = runaway(TR)
check(f"runaway: integrator plateaus at {wi:.0f} W (<= 2000)", wi <= TR.max_w)
check(f"runaway: emitted command peaks at {wc:.0f} W (<= 2000)", wc <= TR.max_w)
check("runaway: nowhere near the historical 14 768 W", wc < HISTORICAL_W / 4)
# ...and with the saturation detector deliberately defeated, so that only the
# clamp is holding. Kill one mechanism, the other still bounds it.
TD = Tuning(max_w=2000, saturation_w=1e9)
wi, wc = runaway(TD)
check(f"runaway with the detector defeated: integrator still bounded ({wi:.0f} W)",
wi <= TD.max_w)
check("runaway with the detector defeated: command still <= max_w", wc <= TD.max_w)
# The mirror: the same runaway driving the other way. An export that never
# clears winds the integrator negative just as hard.
wi, wc = runaway(Tuning(max_w=2000), sign=-1)
check(f"runaway (export direction): integrator bounded at {wi:.0f} W", wi <= 2000)
check("runaway (export direction): emitted command <= max_w", wc <= 2000)
# The bound is a separate quantity, and the useful direction is BELOW max_w:
# there it binds first and caps unwind latency tighter than the rail does.
d = compute(prev_w=0, grid_w=6000, actual_w=0,
tuning=Tuning(max_w=2000, integrator_max_w=1000, slew_w=5000))
check("integrator bound binds independently of the output clamp",
d.i_w == 1000 and d.target_w == 1000)
# Freeze = may not wind further in the direction it is already pushing.
# ⚠️ max_w is raised WELL above the fixtures on purpose. At the default 2000
# the integrator bound truncates a wound value back to exactly 2000 and
# satisfies these assertions on its own, so deleting the freeze outright
# failed nothing - the clamp was standing in for the mechanism under test.
# Any fixture here must sit clear of every rail, or it tests the rail.
TF = Tuning(saturation_w=500, saturation_cycles=3, max_w=5000)
f1 = compute(prev_w=2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
check("frozen: integration does not wind further", f1.i_w == 2000.0 and f1.frozen)
f2 = compute(prev_w=2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
check("frozen: unwinding is still allowed", f2.i_w < 2000.0)
# ...and the same two on the charging side. Every freeze rule in this file has
# a mirror, because the one that did not is the defect that got through review.
f3 = compute(prev_w=-2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
check("frozen (charging): integration does not wind further", f3.i_w == -2000.0)
f4 = compute(prev_w=-2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
check("frozen (charging): unwinding is still allowed", f4.i_w > -2000.0)
# ⚠️ REGRESSION, and the reason the first cut of SAFETY-04 was rejected. A
# freeze encoded as "only corrections that shrink |i_w|" is unsatisfiable for
# BOTH signs of error whenever |correction| > 2*|i_w|, so near zero the loop
# stops moving forever - the freeze cannot clear, because clearing it needs the
# inverter to track and not-tracking is what saturation means. Measured on that
# encoding: 0 W held into a 2 kW import for as long as the sim ran.
z = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T, sat_count=3, i_w=0.0)
check("frozen at i_w=0: a 2 kW import still moves the command",
z.frozen and z.target_w == 1000)
# ...and the next cycle the inverter is inside saturation_w of the command, so
# the freeze clears on its own. Deadlock would show up here as frozen=True.
z2 = compute(prev_w=1000, grid_w=1000, actual_w=600, tuning=T,
sat_count=z.sat_count, i_w=z.i_w)
check("frozen at i_w=0: the freeze then clears", not z2.frozen)
# Same stranding on the other side: a small positive integrator against export.
z3 = compute(prev_w=100, grid_w=-1000, actual_w=800, tuning=T, sat_count=3, i_w=100.0)
check("frozen at i_w=+100: a 1 kW export still moves the command",
z3.frozen and z3.target_w < 0)
z4 = compute(prev_w=-100, grid_w=1000, actual_w=-800, tuning=T, sat_count=3, i_w=-100.0)
check("frozen at i_w=-100: a 1 kW import still moves the command",
z4.frozen and z4.target_w > 0)
# ⚠️ EXACTLY ZERO, BOTH DIRECTIONS. This boundary has a history: the first cut
# deadlocked here under import, and the fix for it deadlocked here under export
# because `if i_w > 0 ... else ...` files 0.0 under rising-only. main.py resets
# i_w to exactly 0.0 on every stop and every reseed, so it is a normal state,
# not a corner.
zi = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T, sat_count=3, i_w=0.0)
check("frozen at i_w=0.0: an import push moves the integrator",
zi.frozen and zi.i_w > 0)
ze = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
check("frozen at i_w=0.0: an export push moves the integrator",
ze.frozen and ze.i_w < 0)
# The COMMAND still holds at 0 W in that second case, and that is release/1.0's
# rule, not a leftover: at prev_w == 0 the output freeze forbids starting to
# charge while saturated, because commanding 0 while the inverter reports
# hundreds of watts means something else is driving the bus. Asserted so that
# nobody "fixes" it by accident - the integrator moving is what this ticket
# owns, the command rule belongs to the output freeze.
#
# ⚠️ SAFETY-13 KEPT THIS ASSERTION RATHER THAN INVERTING IT, and the fixture is
# why it survived unchanged: `sat_count=3` becomes 4, which is below
# SETTLE_CYCLES, so this is still inside the window in which the block holds.
# SAFETY-13 gave the block an EXIT, not a different opinion about its first
# cycles - see the SAFETY-13 section below, where the same state is driven past
# the bound and the release is asserted on the other side of it.
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
ze.target_w == 0.0 and ze.sat_count < SETTLE_CYCLES)
# Where prev_w is already charging the output freeze does NOT block, and there
# the difference reaches the wire: held at 0.0 the integrator abandons the
# charge mid-export.
zc = compute(prev_w=-2000, grid_w=-4000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
check("frozen at i_w=0.0: a charge is not abandoned during heavy export",
zc.target_w == -2000.0)
# The general property, rather than another handful of points: while frozen the
# integrator may be held ONLY when the correction would push it further from
# zero on the side it already sits. Any other hold is a deadlock.
stuck = []
for i0 in [x * 25.0 for x in range(-80, 81)]:
for g in [x * 100.0 for x in range(-40, 41)]:
err = g - T.target_grid_w
if abs(err) < T.deadband_w:
continue
dd = compute(prev_w=0.0, grid_w=g, actual_w=1500.0, tuning=T, sat_count=3, i_w=i0)
if dd.i_w == i0 and not ((i0 > 0 and err > 0) or (i0 < 0 and err < 0)):
stuck.append((i0, g))
check(f"frozen integrator never deadlocks, over {161*81} states"
+ (f" (e.g. {stuck[0]})" if stuck else ""), not stuck)
# False-positive guard: a normal 2 kW load step must not trip the detector,
# because the plant needs several cycles to catch up on every one of them.
prev, actual, sat, i_w, froze = 0.0, 0.0, 0, 0.0, False
for _ in range(12):
d = compute(prev, 2000.0 - actual, actual, T, sat, i_w)
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
actual = actual + 0.94 * (prev - actual)
froze = froze or d.frozen
check("a normal 2 kW load step does not trip the saturation freeze", not froze)
# The convergence sim below runs WITHOUT a carried integrator. This is the same
# 2 kW step in the configuration that actually ships, where main.py carries it.
prev, actual, sat, i_w = 0.0, 0.0, 0, 0.0
carried = 0
for _ in range(12):
d = compute(prev, 2000.0 - actual, actual, T, sat, i_w)
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
actual = actual + 0.94 * (prev - actual)
carried += 1
if abs(2000.0 - actual) < T.deadband_w:
break
check(f"carried integrator converges in {carried} cycles (<=6)", carried <= 6)
check("carried integrator does not overshoot the load", actual <= 2000.0 + T.deadband_w)
# ⚠️ REGRESSION: an integrator allowed to wind past the rail buys nothing (the
# output clamp already bounds the wire) and costs extra cycles of
# wrong-direction power after every saturation event. 4000 W load held to
# saturation, then dropped to 0; the figure is the command on the first cycle
# after the drop. This is what makes the DOCS advice checkable.
def unwind(t):
prev, actual, sat, i_w, load = 0.0, 0.0, 0, 0.0, 4000.0
for c in range(16):
if c == 15:
load = 0.0
d = compute(prev, load - actual, actual, t, sat, i_w)
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
actual = actual + 0.94 * (prev - actual)
return prev
# ⚠️ BOTH FIXTURES CARRY release/1.0's `slew_w` EXPLICITLY (SAFETY-39). The
# subject here is `integrator_max_w`, and the figure the check names - 1000 W on
# the first cycle after the drop - is `max_w - slew_w`, so it moves with the slew
# limit and would stop being the number DOCS.md quotes. Pinning it keeps this a
# test about integrator headroom instead of a test about two mechanisms at once;
# the shipped default's own unwind is measured in the SAFETY-39 section below.
tight = unwind(Tuning(max_w=2000, slew_w=1000.0))
loose = unwind(Tuning(max_w=2000, slew_w=1000.0, integrator_max_w=3000))
check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <= 1000)
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
loose > tight)
print("SAFETY-13: the output freeze has an exit, and only where it needs one")
# THE SHAPE OF THIS SECTION. release/1.0's output freeze holds the command's
# magnitude down while the inverter is not following, and nothing ends it. At
# `prev_w == 0` the held magnitude is ZERO, so it reads as "refuses to start a
# charge"; that is the case the card was written about, but it is one instance
# of a rule that is unbounded at every prev_w. SAFETY-13 bounds it exactly where
# it CANNOT end on its own - see `unasked_power()` - and nowhere else.
# --- 1. the predicate, which is the whole of the decision -------------------
# A limit can only make the inverter do LESS than the command, in the command's
# own direction, so everything a limit can produce lies in the interval spanned
# by 0 and prev_w widened by saturation_w. These are the four cases in the
# docstring, run rather than described.
check("limit: 700 W under a +2000 W command is explainable",
not unasked_power(2000.0, 700.0, 500.0))
check("limit: -700 W under a -5000 W command is explainable",
not unasked_power(-5000.0, -700.0, 500.0))
check("unasked: +700 W under a 0 W command is not",
unasked_power(0.0, 700.0, 500.0))
check("unasked: +700 W under a -800 W command is not",
unasked_power(-800.0, 700.0, 500.0))
# The band edges belong to the limit side, because saturation_w is the same
# tolerance the detector uses - a reading the detector calls "following" must
# never be called "another writer".
check("the saturation band is inclusive on both edges",
not unasked_power(0.0, 500.0, 500.0)
and not unasked_power(0.0, -500.0, 500.0)
and unasked_power(0.0, 500.01, 500.0))
# ⚠️ AT prev_w == 0 THE PREDICATE AND THE DETECTOR ARE THE SAME TEST, which is
# why the zero case is the one that strands: with nothing commanded there is no
# interval for a limit to live in.
same = all(unasked_power(0.0, a, 500.0) == (abs(a) > 500.0)
for a in [x * 5.0 for x in range(-400, 401)])
check("at prev_w == 0 unasked_power IS the saturation test, over 801 readings",
same)
# --- 2. the bound is derived, not chosen ------------------------------------
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_S / LOOP_TICK_S) = {SETTLE_CYCLES}",
SETTLE_CYCLES == 9 and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_S
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_S)
check("...which is the reciprocal of SAFETY-39's SLEW_RAIL_FRAC",
abs(SETTLE_CYCLES - 1.0 / SLEW_RAIL_FRAC) < 1e-9)
# The counter has to be able to REACH the bound or the exit does not exist.
# Driven, not read off the constant: run a saturated state and see how high the
# counter actually goes.
c, sc = 0, 0
while c < 50:
sc = compute(0.0, -2000.0, 900.0, T, sc).sat_count
c += 1
check(f"the saturation counter reaches {sc}, which is above the bound",
sc >= SETTLE_CYCLES)
# --- 3. prev_w == 0: blocked below the bound, released above it -------------
# The same state either side of the bound. This is the pair the card asks for.
blk = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
check("prev_w=0: below the bound the charge is still refused",
blk.target_w == 0.0 and blk.reason == "saturated-freeze"
and blk.sat_count == SETTLE_CYCLES - 1)
rel = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
check(f"prev_w=0: at the bound it is released to {rel.target_w:.0f} W",
rel.target_w < 0.0 and rel.reason == "freeze-released"
and rel.sat_count == SETTLE_CYCLES)
# ...and the released command is the SLEW step from 0, not the rail. A release
# that jumped to the integrator in one cycle would be the windup this ticket
# was told to avoid.
check(f"the released command is exactly one slew step"
f" ({slew_limit_w(T):.0f} W)",
abs(rel.target_w) == slew_limit_w(T))
# The mirror. Nothing here is sign-specific, so the discharge side must behave
# identically - every freeze rule in this file has a mirror, because the one
# that did not is the defect that got through review.
mblk = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
mrel = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
check("prev_w=0, discharging: same block, same release, mirrored",
mblk.reason == "saturated-freeze" and mrel.reason == "freeze-released"
and mrel.target_w == slew_limit_w(T))
# --- 4. non-zero prev_w, and the two halves are NOT the same --------------
# (a) A divergence a LIMIT could have produced is never released, however long
# it stands. This is release/1.0's behaviour and it is kept deliberately: the
# freeze clears itself here, because the command is pinned at something the
# inverter can do.
TL = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
for _ in range(30):
d = compute(prev, 4000.0, max(-1000.0, min(1000.0, prev)), TL, sat, i_w)
prev, i_w, sat, _r = d.target_w, d.i_w, d.sat_count, reasons.add(d.reason)
check("non-zero prev_w, a curtailed inverter: never released over 30 cycles",
"freeze-released" not in reasons and prev == 2400.0)
# (b) A divergence a limit could NOT have produced is released at the bound,
# at a non-zero prev_w exactly as at zero. release/1.0 pinned this run at
# -800 W, against its own integrator standing at -2388 W, for as long as it ran.
prev, i_w, sat, rows = 0.0, 0.0, 0, []
for _ in range(20):
d = compute(prev, -2000.0, 700.0, TL, sat, i_w)
rows.append((prev, d.target_w, d.i_w, d.reason))
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
pinned = [r for r in rows if r[3] == "saturated-freeze"]
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
pinned and all(r[1] == -800.0 for r in pinned))
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
# rests in the deadband with another writer holding the pack at +700 W - so the
# saturation counter climbs while the command is 0 and nothing is wrong - and
# then 2 kW of export arrives. On release/1.0 @ c8dcb38 the command is 0.0 W on
# every cycle from there on: 0 W is a FIXED POINT, and the export is given away
# for as long as the other writer stays.
prev, i_w, sat, cmds = 0.0, 0.0, 0, []
for c in range(24):
d = compute(prev, -10.0 if c < 6 else -2000.0, 700.0, TL, sat, i_w)
cmds.append(d.target_w)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
check("the card's case: the block does hold at first, at 0 W",
cmds[6] == 0.0 and cmds[7] == 0.0)
started = [i for i, c in enumerate(cmds) if c != 0.0]
check(f"the card's case: the charge starts at cycle {started[0]}"
f" instead of never", bool(started))
# ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the
# integrator moved while the output was blocked, and the release must not turn
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
# does not release the INTEGRATOR freeze - so the command can only walk, one
# slew step per cycle, to a value the integrator stopped accumulating at.
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
check(f"the card's case: no cycle moves more than the slew limit "
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
max(steps) <= slew_limit_w(TL))
# --- 6. AC 4, AS A PROPERTY RATHER THAN A TRAJECTORY -----------------------
# ⚠️ THE FIRST FORM OF THIS CHECK WAS WRONG AND THE SWEEP SAID SO, which is
# worth leaving in the file. It asserted `|target| <= |i_w|` on a released
# cycle, and the sweep produced prev_w -5000, i_w -3000, target -4600: the
# command was walking BACK from -5000 toward the integrator and had not
# arrived. It was moving the right way and the assertion still failed, because
# "never exceeds the integrator" is not what no-windup means here.
#
# What it means: a released cycle can only move the command TOWARD the
# integrator, by at most one slew step, and never past it. So the divergence
# that built up while the output was blocked cannot arrive as a jump - it is
# spent one slew step at a time, against an integrator that stopped
# accumulating when the freeze engaged and is not released with the output.
step = float(T.step_w)
past, jumped = [], []
for prev in [x * 250.0 for x in range(-20, 21)]:
for grid in [x * 500.0 for x in range(-12, 13)]:
for actual in [x * 500.0 for x in range(-10, 11)]:
for i0 in (-3000.0, -500.0, 0.0, 500.0, 3000.0):
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
if d.reason != "freeze-released":
continue
lo, hi = min(prev, d.i_w), max(prev, d.i_w)
if not (lo - step <= d.target_w <= hi + step):
past.append((prev, grid, actual, i0, d.target_w, d.i_w))
if abs(d.target_w - prev) > slew_limit_w(TL) + step:
jumped.append((prev, grid, actual, i0, d.target_w))
check(f"over {41*25*21*5} released states the command stays between the"
f" previous command and the frozen integrator"
+ (f" (e.g. {past[0]})" if past else ""), not past)
check("...and no released cycle moves further than one slew step"
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
# --- 7. the one configuration in which the block does not exist ------------
# ⚠️ SAID OUT LOUD BECAUSE IT IS A SILENT NO-OP OTHERWISE. `saturation_cycles`
# is accepted up to 10 by config.yaml. Set it at or above SETTLE_CYCLES and the
# freeze does not engage until the bound has already passed, so the no-start
# block never happens at all. It is not refused at startup - a controller that
# will not boot over a tuning nuance is worse than the nuance - but it must be
# a known consequence rather than a surprise.
TN = Tuning(slew_w=1000.0, saturation_cycles=SETTLE_CYCLES)
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
for _ in range(20):
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
reasons.add(d.reason)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
check(f"saturation_cycles >= {SETTLE_CYCLES} means the block never engages",
"saturated-freeze" not in reasons)
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
def legacy(prev, grid, actual, t, sat_count):
"""release/1.0's control law, transcribed. Do not 'improve' this."""
reason = "tracking"
sc = min(sat_count + 1, 10) if abs(prev - actual) > t.saturation_w else 0
frozen = sc >= t.saturation_cycles
error = grid - t.target_grid_w
if abs(error) < t.deadband_w:
want, reason = prev, "deadband"
else:
want = prev + t.gain * error
target = max(-t.max_w, min(t.max_w, want))
if target != want:
reason = "clamped"
slewed = max(prev - t.slew_w, min(prev + t.slew_w, target))
if slewed != target:
reason = "slew-limited"
target = slewed
if frozen:
target = min(target, prev) if prev > 0 else max(target, prev)
reason = "saturated-freeze"
step = max(1, int(t.step_w))
return float(round(target / step) * step), sc, frozen, reason
# ⚠️ Compare EVERYTHING observable, not just the number. A previous version of
# this sweep compared (target_w, sat_count) only and passed 3024 cases while
# `reason` had silently lost a value - which is the kind of thing a sweep this
# broad exists to catch. `frozen` and `reason` are both in the tuple now.
#
# The one deliberate rename: what release/1.0 called "clamped" is now
# "i-clamped", because the truncation happens on the integrator before the
# command is derived from it. Aliased here rather than papered over - if any
# OTHER reason ever diverges, this check goes red.
# ⚠️ SAFETY-13 IS A DELIBERATE DIVERGENCE FROM `legacy()`, AND THE SWEEP NOW
# GRADES THE DIVERGENCE INSTEAD OF FORBIDDING IT. `legacy()` above stays a
# transcription of release/1.0 - it is not edited to agree, for the reason the
# note on `slew_w` gives: an oracle taught the new rule compares the new law
# against itself. What changed is the assertion. Every case must either be
# IDENTICAL to release/1.0, or be a case where release/1.0 said
# "saturated-freeze" and this law says "freeze-released" with the two conditions
# that permit it both true. Anything else is a regression, and the counts below
# say how much of the sweep each answer covers.
ALIAS = {"i-clamped": "clamped"}
diffs = []
released = []
value_diffs = []
seen = set()
# ⚠️ `slew_w` IS WRITTEN OUT IN ALL THREE (SAFETY-39). `legacy()` below is a
# TRANSCRIPTION of release/1.0 and reads `t.slew_w` directly, as release/1.0
# did; the new law reads `slew_limit_w(t)`, which treats 0 as 'follow the
# rail'. The two agree for every non-zero value and differ at 0 BY DESIGN, so
# a fixture that left the field at its new default would report the design as
# a regression. Fixing it in `legacy()` instead would make this sweep compare
# the new law against itself, which is the one thing it must never do.
for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
Tuning(max_w=5000, slew_w=5000)):
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
for actual in (-2000.0, 0.0, 600.0, 2000.0):
for sc in (0, 2, 3, 9):
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
seen.add(d.reason)
got = (d.target_w, d.sat_count, d.frozen,
ALIAS.get(d.reason, d.reason))
was = legacy(prev, grid, actual, tune, sc)
if got == was:
continue
explained = (
d.reason == "freeze-released"
and was[3] == "saturated-freeze"
and d.sat_count >= SETTLE_CYCLES
and unasked_power(prev, actual, tune.saturation_w))
if not explained:
diffs.append((prev, grid, actual, sc, got, was))
continue
released.append((prev, grid, actual, sc))
if got[0] != was[0]:
value_diffs.append((prev, got[0], was[0]))
TOTAL = 3 * 7 * 9 * 4 * 4
check(f"i_w=None reproduces release/1.0 over {TOTAL} cases except where"
f" SAFETY-13 releases the freeze, reason included"
+ (f" (first unexplained diff {diffs[0]})" if diffs else ""), not diffs)
check(f"{TOTAL - len(released)} of {TOTAL} cases are bit-identical to"
f" release/1.0; {len(released)} are releases", len(released) == 432)
# ⚠️ AND THE RELEASES ARE MOSTLY A LABEL. Only these change what reaches the
# wire; the rest report "freeze-released" on a command the freeze was not
# binding on anyway. Every one of them is a charge that release/1.0 refused to
# start from exactly 0 W, which is the ticket, stated as a count.
check(f"{len(value_diffs)} of those {len(released)} change the command,"
f" and every one is a charge start from prev_w == 0 that was refused",
len(value_diffs) == 27
and all(p == 0.0 and new_ < 0.0 and old_ == 0.0
for p, new_, old_ in value_diffs))
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
# to actually occur in that sweep, or its hook is dead.
check("the integrator clamp reports itself as 'i-clamped'", "i-clamped" in seen)
# "clamped" stays reachable, but only where the integrator is deliberately
# allowed above the rail - then BOTH fire and the output clamp, which describes
# the value actually emitted, is the one reported.
dc = compute(prev_w=0, grid_w=6000, actual_w=0,
tuning=Tuning(max_w=2000, integrator_max_w=3000, slew_w=5000))
check("the output clamp still reports 'clamped' when it is the binding one",
dc.reason == "clamped" and dc.i_w == 3000 and dc.target_w == 2000)
print("capacity tariff")
check("no forecast means no cap", maintenance_charge_floor(2500, None, 3500) == 2500)
check("headroom caps the charge", maintenance_charge_floor(2500, 2000, 3500) == 1500)
check("no headroom means no charge", maintenance_charge_floor(2500, 4000, 3500) == 0)
check("peak risk detected", peak_at_risk(4000, 3500) is True)
check("peak risk off without forecast", peak_at_risk(None, 3500) is False)
print("behaviour: 2 kW load step converges")
# Closed-loop sim. The plant is modelled as first-order-ish: it moves most of
# the way to the command each cycle (measured: 94 % by 3.3 s against a ~5 s
# cycle). House load steps by 2000 W at t=0.
prev, actual, sat, load = 0.0, 0.0, 0, 2000.0
cycles = 0
for i in range(12):
grid = load - actual # what the meter sees
d = compute(prev, grid, actual, T, sat)
prev, sat = d.target_w, d.sat_count
actual = actual + 0.94 * (prev - actual) # plant follows
cycles += 1
if abs(load - actual) < T.deadband_w:
break
check(f"converges within deadband in {cycles} cycles (<=6)", cycles <= 6)
check("no overshoot past the load", actual <= load + T.deadband_w)
print("grid bias: the deadband must not rest on the import register")
# The billed asymmetry: import and export are separate registers, so a resting
# point inside the deadband on the import side is paid for every second it
# holds. 14 W held all day is 0.34 kWh.
T0 = Tuning(target_grid_w=0.0)
TB = Tuning(target_grid_w=-10.0)
check("unbiased, +14 W import rests forever",
compute(500.0, 14.0, 500.0, T0).reason == "deadband")
d = compute(500.0, 14.0, 500.0, TB)
check("biased, the same +14 W is corrected", d.reason != "deadband" and d.target_w > 500.0)
check("biased, a small export rests", compute(500.0, -10.0, 500.0, TB).reason == "deadband")
check("biased, the band still ends before -25 W",
compute(500.0, -30.0, 500.0, TB).reason != "deadband")
# Worst-case billed leak: the band is [bias - deadband, bias + deadband], so it
# drops from 15 W to 5 W. Set target_grid_w to -deadband_w to remove it entirely,
# at the cost of giving that much away as export.
check("worst billed rest point falls from 15 W to under 5 W",
compute(500.0, 4.9, 500.0, TB).reason == "deadband"
and compute(500.0, 5.0, 500.0, TB).reason != "deadband")
print("SAFETY-39: the slew limit is a RATIO to the rail, not a number")
# ⚠️ WHAT WENT WRONG, IN ONE LINE, BECAUSE THE SHAPE IS THE POINT. `slew_w:
# 1000` was derived on 2026-08-22 against a clamp of 1500 W and its stated
# justification was that it caps "how far the loop can jump ahead of the
# inverter". The live install then ran max_w 5000 with the same 1000, and on
# 2026-09-05 11:43-11:46 a real 3.4 kW export drove the setpoint 380 -> 5000 W
# in five 2 s ticks, the battery to -5154 W against a 5000 VA inverter, and the
# grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken. The rail
# moved and the constant did not.
#
# So what is graded below is the RELATIONSHIP and not a value: at every rail,
# the loop takes the plant's own settling time to cross its whole output range.
# A check that pinned "555 W" would go stale the same way the comment did.
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = {SLEW_RAIL_FRAC:.4f}"
f" ({1 / SLEW_RAIL_FRAC:.0f} ticks to cross the rail)")
check("0 means FOLLOW THE RAIL and resolves to SLEW_RAIL_FRAC * max_w",
slew_limit_w(Tuning(max_w=5000.0, slew_w=0.0)) == SLEW_RAIL_FRAC * 5000.0)
check("an explicit value is passed through untouched - it is the operator's "
"number and nothing here silently reduces it",
slew_limit_w(Tuning(max_w=5000.0, slew_w=400.0)) == 400.0)
check("the shipped Tuning default is 0, i.e. the rail, so the law carries no "
"second constant to go stale beside config.yaml",
Tuning().slew_w == 0.0)
# ⭐ THE RELATIONSHIP ITSELF, over every rail this add-on can be built at: the
# single-inverter schema range (100..5000), the live install (5000), and the
# roster sums a second inverter produces - 9600 with inverter B's datasheet
# figure, 10000 with two of A. ⚠️ 9600 IS NOT A MEASUREMENT: inverter B does not
# exist on site yet (SPIKE-04, ~2026-09-12) and 4600 W is its datasheet and
# serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what
# is being graded, and the arithmetic does not care.
_rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0]
_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails}
print(" ticks to cross the rail at the default, by rail: "
+ ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items()))
check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - "
"which is PLANT_SETTLE_S at the measured tick. This is the whole of "
"SAFETY-39: raising max_w moves the slew limit with it, so the constant "
"cannot be left behind by the rail again",
all(abs(t - PLANT_SETTLE_S / LOOP_TICK_S) < 1e-9 for t in _ticks.values()))
check("...and that is the same as saying the crossing takes the plant's own "
f"measured settling time ({PLANT_SETTLE_S:.0f} s) at the "
f"{LOOP_TICK_S:.0f} s tick the live install was measured running",
all(abs(t * LOOP_TICK_S - PLANT_SETTLE_S) < 1e-9 for t in _ticks.values()))
# ⭐ VERIFIED BY BREAKING IT. The configuration that produced the 2026-09-05
# excursion, put through the same arithmetic: it crosses the rail in 10 s
# against a plant that takes 18 s. This is the number validate.py rule 10
# refuses on, and it is asserted HERE too so that the law and the gate cannot
# drift apart - the rule can only ever refuse what this ratio says is too fast.
_broken = Tuning(max_w=5000.0, slew_w=1000.0)
_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S
print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its "
f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s")
check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by "
"the ratio rather than by anybody's opinion - test_validate.py's rule 10 "
"section refuses exactly this pair",
_broken_s < PLANT_SETTLE_S
and slew_limit_w(_broken) > SLEW_RAIL_FRAC * _broken.max_w)
# THE CLOSED LOOP, AND WHAT THIS PLANT IS AND IS NOT.
#
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
# `plantsim.Plant` is the battery model this project grades control changes
# against, and this repository's CI cannot reach it - that repository is private
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
# HERE is a first-order lag with the time constant that reproduces §5.10's
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
# same reason and says so in the same words.
#
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
# plant exceed `p_max`; both clamp. The -5154 W against a 5000 VA inverter that
# the ticket reports is therefore NOT REPRODUCIBLE IN SIMULATION AT ALL, by
# construction, and no acceptance criterion here is written on it. What IS
# reproducible, and is what is graded, is the loop OVER-ABSORBING and driving
# the meter from export into import - the error it then chases.
_TAU_S = 6.0 # first-order lag; with the 2 s tick it reaches the
# deadband ~18 s after a step, which is §5.10's figure
def _step_run(tuning, export_w, load_w=350.0, phase=5, cycles=90,
dt=LOOP_TICK_S):
"""One export step onto a converged loop.
-> (worst import at the meter, peak battery beyond what the site NEEDED,
largest setpoint change in one tick).
"""
import math
prev, i_w, sat, actual, pv = 0.0, None, 0, 0.0, 0.0
a = 1.0 - math.exp(-dt / _TAU_S)
worst_import, peak, biggest, last = -1e9, 0.0, 0.0, None
for c in range(cycles):
if c == phase:
pv = export_w + load_w
grid = load_w - pv - actual
d = compute(prev, grid, actual, tuning, sat, i_w)
if c >= phase and last is not None:
biggest = max(biggest, abs(d.target_w - last))
last = d.target_w
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
# the plant: a first-order lag, clamped at the rating like the real one
actual += (max(-tuning.max_w, min(tuning.max_w, prev)) - actual) * a
if c >= phase:
worst_import = max(worst_import, load_w - pv - actual)
peak = max(peak, abs(actual))
return worst_import, peak - export_w, biggest
def _family(tuning):
"""The worst case over 51 export steps, 1.0-4.2 kW, at three phases.
⚠️ A FAMILY AND NOT ONE STEP, AND THAT IS A FINDING RATHER THAN CAUTION.
A single run of this loop is not evidence: the deadband, the 10 W
quantisation and the phase of the step against the tick move the result by
hundreds of watts, non-monotonically, so two adjacent slews can rank either
way on one step and the opposite way on the next. Every number below is a
worst case over the family, which is how admin/goodwe grades the same
quantities (see `config.yaml`'s "1890 closed-loop runs" and "924 runs").
"""
worst = dict(imp=-1e9, over=-1e9, step=0.0, big=0, runs=0)
for export_w in range(1000, 4300, 200):
for phase in (5, 6, 7):
imp, over, step = _step_run(tuning, float(export_w), phase=phase)
worst["runs"] += 1
worst["imp"] = max(worst["imp"], imp)
worst["over"] = max(worst["over"], over)
worst["step"] = max(worst["step"], step)
worst["big"] += step >= 900.0
return worst
_LIVE = Tuning(max_w=5000.0, slew_w=1000.0) # what ran on 2026-09-05
_RAIL = Tuning(max_w=5000.0) # slew_w 0 - follow the rail
_INTERIM = Tuning(max_w=5000.0, slew_w=400.0) # applied live 2026-09-05 12:00
_SLOWER = Tuning(max_w=5000.0, slew_w=250.0)
_GAIN03 = Tuning(max_w=5000.0, gain=0.3)
_GAIN02 = Tuning(max_w=5000.0, gain=0.2)
_R = {k: _family(t) for k, t in
(("live", _LIVE), ("rail", _RAIL), ("interim", _INTERIM),
("slower", _SLOWER), ("gain03", _GAIN03), ("gain02", _GAIN02))}
print(f" worst case over {_R['live']['runs']} export steps at max_w 5000, "
f"{LOOP_TICK_S:.0f} s tick:")
for _k, _lab in (("live", "gain 0.6 slew_w 1000 (the 2026-09-05 live pair)"),
("rail", "gain 0.6 slew_w 0 (follow the rail)"),
("interim", "gain 0.6 slew_w 400 (the live interim)"),
("slower", "gain 0.6 slew_w 250"),
("gain03", "gain 0.3 slew_w 0"),
("gain02", "gain 0.2 slew_w 0")):
_w = _R[_k]
print(f" {_lab:<48} largest tick step {_w['step']:5.0f} W, "
f"{_w['big']:2}/{_w['runs']} runs step >= 900 W, "
f"over-absorbed {_w['over']:5.0f} W, meter driven to {_w['imp']:+6.0f} W")
# ⭐ THE DEFECT, REPRODUCED BEFORE ANYTHING IS CHANGED. The live pair steps the
# setpoint by a full 1000 W on nearly every member of the family and drives the
# meter from EXPORT into IMPORT - the loop manufacturing the error it then
# chases. That is the 2026-09-05 observation and it is what makes this a control
# fault rather than a slow loop.
check("⭐ REPRODUCED: at max_w 5000 with slew_w 1000 the setpoint steps a full "
"1000 W per tick on nearly every step in the family, and the meter is "
"driven from export into import",
_R["live"]["step"] >= 900.0
and _R["live"]["big"] > 0.9 * _R["live"]["runs"]
and _R["live"]["imp"] > 200.0)
check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail removes "
"the >= 900 W step from EVERY member of the family - which is the symptom "
"that escalated 2 -> 1 -> 13 -> 43 per 30 min on the morning of the "
"2026-09-05 excursion",
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
and _R["interim"]["big"] == 0)
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
# integrator accumulates `gain * error` per cycle and is bounded only by
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
# not reduce it - on this model it gets slightly WORSE, because the command
# spends longer away from where it needs to be while the integrator keeps
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
# 863 W at gain 0.3 (tools/slew_sim.py).
#
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
# of them moving in one release makes the next measurement uninterpretable.
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
"slew from 1000 to 250 does not reduce the over-absorption at all",
_R["slower"]["over"] >= _R["live"]["over"])
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
"the same slew. That is the measurement behind 'gain is the next constant "
"to re-derive', and it is a measurement rather than an opinion",
_R["gain03"]["over"] < _R["rail"]["over"]
and _R["gain02"]["over"] < _R["gain03"]["over"])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")