checks / unit (pull_request) Successful in 5m30s
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 pristinec8dcb38first), 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
925 lines
51 KiB
Python
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")
|