checks / unit (pull_request) Successful in 8m44s
Checks and prints that indexed an empty list, exhausted next(), or formatted None killed the rest of their file (FCST-06). Read them through the new named_absence.py (nth/first/field/given/present) and conjoin a presence term. - every site in the brief, plus the family the new scan and a sweep found - test_named_absence.py: scans every test_*.py for unguarded filtered-list indexing, next() without a default and numeric formats on maybe-None names; proven to bite; prints what it cannot see, counted - test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the two assemble() calls' arguments - test_control.py: coverage table re-measured on runs graded to the end Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1166 lines
64 KiB
Python
1166 lines
64 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 named_absence import nth
|
|
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_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 TEST-26 on a purged tree in the CI image, each run graded to the
|
|
# end of the file (98/99 where the `if _bad:` check is skipped). Before TEST-26
|
|
# the rows marked * killed the file with an IndexError or ZeroDivisionError in
|
|
# a check, so their earlier figures were short; those checks now fail by name.
|
|
#
|
|
# mechanism in compute(), and the exact form deleted checks that fail
|
|
# --------------------------------------------------------- ----------------
|
|
# integrator freeze: `if not frozen or i_w == 0.0` -> `if True` 6
|
|
# integrator clamp: `bounded = i_w` 6
|
|
# integrator bound follows max_w: default `limit = 5000.0` 3
|
|
# output clamp: `target = want` 3
|
|
# slew limit: `slewed = target` 17
|
|
# slew follows the rail (SAFETY-39): `slew_limit_w()` returns
|
|
# `abs(float(tuning.slew_w))` * 9
|
|
# output freeze: the `target = min(...)/max(...)` line removed 7
|
|
# output freeze: `if frozen:` -> `if False:` * 14
|
|
# the output freeze's EXIT (S13): its `if` -> `if False` * 11
|
|
# `unasked_power()` returns True (S13) 6
|
|
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
|
|
# `_SAT_COUNT_CAP = 10`, release/1.0's literal (S13) * 11
|
|
# deadband: its `if` -> `if False` 7
|
|
# quantisation: `target = target` 4
|
|
# saturation detector: `saturated_now = False` * 27
|
|
# saturation detector: `frozen = False` * 25
|
|
# saturation duration fires instantly: `sat_count >= 1` 5
|
|
# sat counter not reset on a good cycle: `else sat_count` 5
|
|
# target_grid_w bias: `error = grid_w` 5
|
|
# i_w=None seeding from prev_w: `i_w = 0.0` 11
|
|
#
|
|
# ⚠️ 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.
|
|
#
|
|
# Two things under-report a figure here, both in the same direction: stale
|
|
# bytecode, and a check that raises and stops the file before the rest are
|
|
# graded (MUTATION.md, FCST-06). The second is what the * rows above had.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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, and from WHICH HALF of the measurement --------
|
|
# ⚠️ THE DIRECTION IS THE ASSERTION. goodwe/NOTES.md 5.10 measured the same
|
|
# 1.78 kW step both ways: ~18 s up, ~23 s down. SAFETY-39 takes the SHORTER
|
|
# one because its bound is a REFUSAL and the looser number refuses less. This
|
|
# bound asks the opposite question - how long the plant may still be the
|
|
# explanation for a divergence - and for that the conservative half is the
|
|
# LONGER one. The first version of this file derived it from 18, got 9, and
|
|
# that is three cycles short of the plant's own measured down-ramp, in the
|
|
# direction that ACCUSES rather than refuses (section 8 below drives it).
|
|
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) ="
|
|
f" {SETTLE_CYCLES}",
|
|
SETTLE_CYCLES == 12
|
|
and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_DOWN_S
|
|
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_DOWN_S)
|
|
check("...from the LONGER of the two measured settle times, so it is strictly"
|
|
" above what the shorter one would have given",
|
|
PLANT_SETTLE_DOWN_S > PLANT_SETTLE_S
|
|
and SETTLE_CYCLES > -(-PLANT_SETTLE_S // LOOP_TICK_S))
|
|
# ...and it is NOT SLEW_RAIL_FRAC's reciprocal any more. It was, while both
|
|
# were being derived from the same half of the pair, and that coincidence read
|
|
# as a derivation. Asserted as an INEQUALITY so the two cannot silently be
|
|
# re-fused into one number again.
|
|
check("the slew's rail crossing and this bound are now different numbers",
|
|
SETTLE_CYCLES > 1.0 / SLEW_RAIL_FRAC)
|
|
# 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 {nth(pinned, 0)[1]:.0f} W...",
|
|
pinned and all(r[1] == -800.0 for r in pinned))
|
|
# ⚠️ SAFETY-48 MOVED THIS NUMBER FROM -2390 W TO -1200 W, DELIBERATELY, AND
|
|
# THE PROPERTY IT IS HERE FOR IS UNCHANGED. The release lets the command walk
|
|
# to the FROZEN INTEGRATOR; SAFETY-48 bounds how far that integrator can have
|
|
# wound ahead of the command before the freeze engaged, so the value it walks
|
|
# to is smaller. What this run must still show is that the release happens and
|
|
# that it stops at the integrator - both asserted, the second against `i_w`
|
|
# rather than against a literal, because the literal is the half this card
|
|
# moves. The cost is stated rather than smoothed: on a persistent second writer
|
|
# this run buys back 1200 W of a 2000 W export where release/1.0 bought back
|
|
# 2390 W. DECISIONS/SAFETY-48 §5 records it as the price of the bound and the
|
|
# rejected alternative that avoids it.
|
|
check(f"...then released, settling at {prev:.0f} W, at the frozen integrator "
|
|
f"({i_w:.0f} W) - release/1.0 settled at -2390 W, at its own unbounded "
|
|
f"one", prev == -1200.0 and prev == i_w)
|
|
|
|
# --- 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",
|
|
nth(cmds, 6) == 0.0 and nth(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 {nth(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.
|
|
# ⚠️ SAFETY-48 MOVED THIS ONE TOO, -1190 W -> -800 W against an integrator that
|
|
# stood at -1194 W and now stands at -800 W, for the reason above. AC 4's
|
|
# property - the release stops at the integrator and not at the rail - is what
|
|
# is asserted; the literal is the branch's own number and is printed.
|
|
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 - release/1.0 settled "
|
|
f"at -1190 W against an integrator of -1194 W",
|
|
prev == -800.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.
|
|
# ⚠️ AND THE COUNT BELOW IS THE RELEASED STATES, NOT THE SWEPT ONES. The first
|
|
# version of this check named 41*25*21*5 - the size of the sweep - while
|
|
# grading only the subset that actually releases, so the number in the message
|
|
# was not the number the assertion was about. It is counted now.
|
|
step = float(T.step_w)
|
|
swept, past, jumped = 0, [], []
|
|
n_released = 0
|
|
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):
|
|
swept += 1
|
|
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
|
|
if d.reason != "freeze-released":
|
|
continue
|
|
n_released += 1
|
|
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"of {swept} states swept, the {n_released} that release keep the command"
|
|
f" between the previous command and the frozen integrator"
|
|
+ (f" (e.g. {past[0]})" if past else ""), not past and n_released > 0)
|
|
check("...and no released cycle moves further than one slew step"
|
|
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
|
|
|
|
# --- 7. NO ACCEPTED CONFIGURATION CAN DELETE THE BLOCK ---------------------
|
|
# ⚠️ THIS CHECK REPLACES ITS OWN OPPOSITE, AND THE REASON IS WORTH KEEPING.
|
|
# While the bound was 9, `saturation_cycles` - which config.yaml accepts up to
|
|
# 10 - could be set AT OR ABOVE it, and then the freeze did not engage until
|
|
# its own exit was already due: the no-start block silently stopped existing,
|
|
# and this file asserted that as a known consequence. Moving the bound to 12
|
|
# put the whole accepted range strictly below it, so the degenerate
|
|
# configuration is gone rather than documented.
|
|
#
|
|
# It is pinned against config.yaml ITSELF and not a transcription, because the
|
|
# property is "two files agree" and a transcription is what lets them drift.
|
|
# Regex, not PyYAML: the add-on image ships no PyYAML (test_validate.py says
|
|
# the same thing at more length).
|
|
import os # noqa: E402
|
|
import re # noqa: E402
|
|
|
|
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
|
with open(_CFG, encoding="utf-8") as _fh:
|
|
_m = re.search(r"^ saturation_cycles: int\((\d+),(\d+)\)\s*$",
|
|
_fh.read(), re.M)
|
|
check("config.yaml's saturation_cycles range really was read", _m is not None)
|
|
_sc_lo, _sc_hi = int(_m.group(1)), int(_m.group(2))
|
|
check(f"every accepted saturation_cycles ({_sc_lo}..{_sc_hi}) is strictly"
|
|
f" below the bound ({SETTLE_CYCLES}), so no accepted configuration can"
|
|
f" push the freeze's engagement past its own exit",
|
|
_sc_hi < SETTLE_CYCLES)
|
|
# Driven at the schema's edge rather than argued: the block still engages, and
|
|
# it engages before the exit is due.
|
|
TN = Tuning(slew_w=1000.0, saturation_cycles=_sc_hi)
|
|
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
|
|
first_freeze = None
|
|
for _ in range(20):
|
|
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
|
|
reasons.add(d.reason)
|
|
if d.reason == "saturated-freeze" and first_freeze is None:
|
|
first_freeze = d.sat_count
|
|
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
|
check(f"at the largest accepted saturation_cycles ({_sc_hi}) the block still"
|
|
f" engages, at sat_count {first_freeze}, below the bound",
|
|
"saturated-freeze" in reasons and first_freeze is not None
|
|
and first_freeze < SETTLE_CYCLES)
|
|
|
|
# --- 8. THE BOUND'S DIRECTION, DRIVEN ON ONE INVERTER ----------------------
|
|
# ⚠️ WHY THIS SECTION EXISTS. The exit's two conditions are read as "another
|
|
# writer is on the actuator". They are not that, and the first shipped form of
|
|
# this file said they were. `unasked_power()` compares a reading against ONE
|
|
# cycle's command while the pack carries every command before it, so a command
|
|
# that has fallen THROUGH ZERO - or simply fallen faster than the pack coasts -
|
|
# leaves our own power outside our own interval. With the bound derived from
|
|
# the 18 s UP measurement that is not a corner case: it is what a normal load
|
|
# step does.
|
|
#
|
|
# The plant here is a MODEL and is labelled one. It is a first-order lag whose
|
|
# two time constants come from the two measured settling times (a first-order
|
|
# step is 95 % done at 3 tau, so tau = t / 3), with the ramp cap
|
|
# sim/plantsim.py measured on inverter A. It is not the rig and it is not the
|
|
# house; what it is for is that the two settling times ENTER the model in the
|
|
# same direction they enter the constant, so the check moves if either does.
|
|
#
|
|
# ⚠️ AND THERE IS NO SECOND WRITER IN THIS SECTION. One `compute()`, one
|
|
# plant, one command. Every `freeze-released` below is therefore false.
|
|
import math # noqa: E402
|
|
|
|
|
|
class _Plant:
|
|
"""One inverter, obeying our command and nothing else. A MODEL."""
|
|
|
|
RAMP_W_S = 800.0 # sim/plantsim.py INVERTER_A, measured=True
|
|
|
|
def __init__(self, up_s, down_s):
|
|
self.a_up = 1.0 - math.exp(-LOOP_TICK_S / (up_s / 3.0))
|
|
self.a_dn = 1.0 - math.exp(-LOOP_TICK_S / (down_s / 3.0))
|
|
self.w = 0.0
|
|
|
|
def tick(self, cmd):
|
|
a = self.a_up if abs(cmd) > abs(self.w) else self.a_dn
|
|
cap = self.RAMP_W_S * LOOP_TICK_S
|
|
self.w += max(-cap, min(cap, a * (cmd - self.w)))
|
|
|
|
|
|
from app import control as _control # noqa: E402
|
|
|
|
TS8 = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0,
|
|
saturation_w=500.0, saturation_cycles=3)
|
|
|
|
|
|
def _drive(bound, load1, load2, cycles=60, switch=25):
|
|
"""Run one closed loop at an arbitrary bound. -> (release cycles, rows)."""
|
|
was_b, was_c = _control.SETTLE_CYCLES, _control._SAT_COUNT_CAP
|
|
# ⚠️ BOTH, OR THE PROBE LIES: `_SAT_COUNT_CAP` is what `compute()` caps the
|
|
# counter with, so moving the bound without it caps the counter BELOW the
|
|
# bound and the exit quietly becomes unreachable - which reads as "no false
|
|
# positives". That is the hazard `_SAT_COUNT_CAP`'s own comment names.
|
|
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = bound, bound + 1
|
|
try:
|
|
p = _Plant(PLANT_SETTLE_S, PLANT_SETTLE_DOWN_S)
|
|
prev, i_w, sat, rows = 0.0, 0.0, 0, []
|
|
for c in range(cycles):
|
|
load = load1 if c < switch else load2
|
|
d = compute(prev, load - p.w, p.w, TS8, sat, i_w)
|
|
rows.append((c, prev, p.w, d.target_w, d.sat_count, d.reason))
|
|
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
|
p.tick(prev)
|
|
return [r for r in rows if r[5] == "freeze-released"], rows
|
|
finally:
|
|
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = was_b, was_c
|
|
|
|
|
|
_SHORT = int(-(-PLANT_SETTLE_S // LOOP_TICK_S)) # what 18 s would give: 9
|
|
|
|
# A 4 kW export falling to 2.5 kW. Nothing exotic; no second writer.
|
|
_bad, _rows = _drive(_SHORT, -4000.0, -2500.0)
|
|
check(f"a bound derived from the SHORTER measurement ({_SHORT} cycles) fires"
|
|
f" the exit on ONE inverter with nothing else on the bus"
|
|
+ (f" - {len(_bad)} cycle(s), first at cycle {_bad[0][0]}" if _bad else ""),
|
|
bool(_bad))
|
|
if _bad:
|
|
_c, _prev, _act, _tgt, _sat, _ = _bad[0]
|
|
check(f"...and what it called unasked is our own coast-down: command"
|
|
f" {_prev:.0f} W, pack still at {_act:.1f} W, and the pack was"
|
|
f" commanded {min(r[1] for r in _rows[:_c]):.0f} W a few cycles"
|
|
f" earlier",
|
|
unasked_power(_prev, _act, TS8.saturation_w)
|
|
and abs(_act) > abs(_prev)
|
|
and min(r[1] for r in _rows[:_c]) < _act)
|
|
_good, _ = _drive(SETTLE_CYCLES, -4000.0, -2500.0)
|
|
check(f"the shipped bound ({SETTLE_CYCLES} cycles, the LONGER measurement)"
|
|
f" does not fire on the same run", not _good)
|
|
# ...and not on any of these, either. The whole grid of load steps the same
|
|
# plant can be given, at the tuning every headline figure in this ticket is
|
|
# quoted from.
|
|
_L = [x * 500.0 for x in range(-8, 9)]
|
|
_fired = {}
|
|
for _b in (_SHORT, SETTLE_CYCLES):
|
|
_fired[_b] = sum(1 for _a in _L for _z in _L
|
|
if _a != _z and _drive(_b, _a, _z)[0])
|
|
_pairs = len(_L) * (len(_L) - 1)
|
|
check(f"over {_pairs} load-step pairs on the measured plant, one inverter and"
|
|
f" no second writer: the short bound fires in {_fired[_SHORT]} of them,"
|
|
f" the shipped bound in {_fired[SETTLE_CYCLES]}",
|
|
_fired[SETTLE_CYCLES] < _fired[_SHORT])
|
|
# ⚠️ AND THE SHIPPED FIGURE IS NOT ZERO IN GENERAL. It is small here because
|
|
# the plant in this model is the one that was measured. A plant slower than
|
|
# that brings the false release back, and no constant derived from a
|
|
# measurement can fix that - it is why DOCS.md tells the operator what
|
|
# `freeze-released` is evidence OF, rather than what it proves.
|
|
|
|
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"}
|
|
# ⚠️ SAFETY-13 ROUND 2 MOVED THE COUNTER'S CEILING, SO THE SWEEP NOW HAS A
|
|
# THIRD ANSWER. `_SAT_COUNT_CAP` is `SETTLE_CYCLES + 1`, which was 10 - the
|
|
# same literal release/1.0 used - and is now 13. Above release/1.0's own
|
|
# ceiling the two counters therefore differ, and NOTHING ELSE MAY: `frozen` is
|
|
# `sat_count >= saturation_cycles` and config.yaml accepts that at most 10, so
|
|
# a counter reading 13 instead of 10 cannot change it, and no other line in
|
|
# `compute()` reads the counter's value. That is asserted rather than asserted-
|
|
# by-omission, in `cap_diffs` below.
|
|
#
|
|
# ⚠️ AND THE SWEEP HAS TO REACH THE BOUND OR IT GRADES NOTHING. `sc` runs to 12
|
|
# now; at 9 the counter tops out one short of the exit and every release
|
|
# silently disappears from this table, which is exactly what happened when the
|
|
# bound moved and is why the count is asserted and not merely printed. 12 is a
|
|
# state release/1.0 could never hold - its counter stopped at 10 - so
|
|
# `legacy()` is being fed one input outside its own reachable domain, on
|
|
# purpose, and `cap_diffs` is where that shows up.
|
|
LEGACY_SAT_CAP = 10 # release/1.0's literal, transcribed in legacy()
|
|
diffs = []
|
|
released = []
|
|
value_diffs = []
|
|
cap_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, 12):
|
|
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
|
|
# everything that reaches the wire, without the counter
|
|
wire_same = (got[0], got[2], got[3]) == (was[0], was[2], was[3])
|
|
if wire_same:
|
|
# the ceiling moved and nothing else did. Only legal
|
|
# above release/1.0's own cap, and only with both
|
|
# counters standing at their own ceilings.
|
|
if (sc >= LEGACY_SAT_CAP
|
|
and was[1] == LEGACY_SAT_CAP
|
|
and got[1] == _control._SAT_COUNT_CAP):
|
|
cap_diffs.append((prev, grid, actual, sc, got[1], was[1]))
|
|
continue
|
|
diffs.append((prev, grid, actual, sc, 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 * 5
|
|
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)
|
|
# ⚠️ EXACT COUNTS, NOT INEQUALITIES. A weaker form of this line ("some
|
|
# releases happened") was written while the bound was moving, and the coverage
|
|
# audit caught it immediately: a `unasked_power()` that stops discriminating
|
|
# went from 6 checks to 5. The three numbers partition the sweep, so any one of
|
|
# them moving is a change somebody has to explain.
|
|
check(f"{TOTAL - len(released) - len(cap_diffs)} of {TOTAL} cases are"
|
|
f" bit-identical to release/1.0; {len(released)} are releases;"
|
|
f" {len(cap_diffs)} differ in the saturation counter alone",
|
|
len(released) == 432 and len(cap_diffs) == 81
|
|
and TOTAL - len(released) - len(cap_diffs) == 3267)
|
|
# ⚠️ THE COUNTER-ONLY DIFFERENCES ARE NOT A LOOPHOLE, so they are pinned twice:
|
|
# they may only occur at an `sc` above release/1.0's ceiling, and the largest
|
|
# `saturation_cycles` config.yaml accepts is below BOTH ceilings, so no
|
|
# accepted configuration can see the difference at all.
|
|
check(f"the {len(cap_diffs)} counter-only differences all sit above"
|
|
f" release/1.0's cap of {LEGACY_SAT_CAP}, where no accepted"
|
|
f" saturation_cycles can read them",
|
|
all(r[3] >= LEGACY_SAT_CAP for r in cap_diffs) and _sc_hi <= LEGACY_SAT_CAP)
|
|
# ⚠️ 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]
|
|
# A zero limit grades as nan ticks (a named FAIL), not a ZeroDivisionError.
|
|
_ticks = {r: r / (slew_limit_w(Tuning(max_w=r)) or float("nan")) 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) or float("nan")) * 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)
|
|
|
|
# --- SAFETY-48 RE-BASELINED THIS CHECK, AND THE INVERSION IS THE EVIDENCE ----
|
|
#
|
|
# ⚠️ WHAT THIS LINE USED TO SAY, AND WHY IT IS NOT A SILENT EDIT. SAFETY-39
|
|
# shipped:
|
|
#
|
|
# "⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION ... Quartering the slew
|
|
# from 1000 to 250 does not reduce the over-absorption at all"
|
|
# asserted as _R["slower"]["over"] >= _R["live"]["over"]
|
|
#
|
|
# and the reasoning under it was right about the mechanism and therefore wrong
|
|
# about the conclusion: `slew_w` bounds the OUTPUT while the integrator
|
|
# accumulates `gain * error` and is bounded only by `integrator_max_w`, so
|
|
# slowing the slew leaves the integrator winding for LONGER and the
|
|
# over-absorption got WORSE. Measured on this model at release/1.0
|
|
# (`68c5ede`), worst case over the same 51-step family:
|
|
#
|
|
# slew_w 1000 over-absorbed 1005 W
|
|
# slew_w 0 (rail, 556) 1109 W <- SLOWER IS WORSE
|
|
# slew_w 400 1285 W
|
|
# slew_w 250 1330 W
|
|
#
|
|
# SAFETY-48 bounds the integrator's LEAD at one slew step, which is exactly the
|
|
# quantity that sentence describes. The same six runs on this branch:
|
|
#
|
|
# slew_w 1000 over-absorbed 729 W
|
|
# slew_w 0 (rail, 556) 550 W <- AND NOW SLOWER IS BETTER
|
|
# slew_w 400 424 W
|
|
# slew_w 250 326 W
|
|
#
|
|
# So the claim is inverted rather than weakened, on a plant model this file
|
|
# writes itself and SAFETY-48's 15.7-day replay of the live install's recorded
|
|
# disturbances has never seen. The two agree on the sign, which is worth more
|
|
# than either alone. ⚠️ The old direction is asserted as a NEGATIVE below so
|
|
# that a revert of SAFETY-48 cannot quietly restore it and leave this section
|
|
# green on the other law.
|
|
#
|
|
# `gain` is still the next constant to re-derive, and SAFETY-48 CHANGED NOTHING
|
|
# ABOUT IT: 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("⭐ SAFETY-48: WITH THE INTEGRATOR'S LEAD BOUNDED, A SLOWER SLEW REDUCES "
|
|
"THE OVER-ABSORPTION - 1000 -> rail -> 400 -> 250 falls monotonically, "
|
|
f"{_R['live']['over']:.0f} -> {_R['rail']['over']:.0f} -> "
|
|
f"{_R['interim']['over']:.0f} -> {_R['slower']['over']:.0f} W. On "
|
|
"release/1.0 the same four runs RISE: 1005 -> 1109 -> 1285 -> 1330 W",
|
|
_R["rail"]["over"] < _R["live"]["over"]
|
|
and _R["interim"]["over"] < _R["rail"]["over"]
|
|
and _R["slower"]["over"] < _R["interim"]["over"])
|
|
check("...and every member of the family over-absorbs LESS than release/1.0's "
|
|
"own figure for that member - the recorded four are 1005, 1109, 1285 and "
|
|
"1330 W, taken on 68c5ede by running this file",
|
|
_R["live"]["over"] < 1005.0 and _R["rail"]["over"] < 1109.0
|
|
and _R["interim"]["over"] < 1285.0 and _R["slower"]["over"] < 1330.0)
|
|
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")
|