SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
checks / unit (pull_request) Successful in 5m16s
checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where "capping how far the loop can jump ahead of the inverter" was true because the rail itself bounded the excursion. The live install went to max_w 5000 and the constant did not move, so the loop crosses its whole output range in 10 s against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five 2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved. REPRODUCED FIRST, and one half of it is not reproducible at all. On plantsim.Plant and on an independent in-repo model: the setpoint stepping a full 1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from export into import. The -5154 W figure is NOT reproducible in either model, because both clamp the plant at its rating - so the card's AC "battery no longer exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met. THE DELIVERABLE IS THE RELATIONSHIP. SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9 slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as integrator_max_w = 0 already means follow max_w. It is the shipped default: 222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. validate.py rule 10 refuses an explicit value faster than that, graded against sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a second inverter moves the bound with no operator action. VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the refusal names the rail, the crossing time, the measurement and what to do. Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating all still start. GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51 export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250) and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W rail in under three ticks whatever the slew is. Two control-law constants moving in one release on a running house makes the next measurement uninterpretable. ⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant did - both stated in the record rather than left to be discovered. Nothing reached the inverter. The live install still runs the interim slew_w 400 applied by hand on 2026-09-05, which rule 10 accepts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -17,9 +17,19 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import sys
|
||||
|
||||
from app.control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC,
|
||||
Tuning, compute, slew_limit_w,
|
||||
maintenance_charge_floor, peak_at_risk)
|
||||
|
||||
T = Tuning()
|
||||
# ⚠️ 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 = []
|
||||
|
||||
|
||||
@@ -49,6 +59,13 @@ def check(name, cond):
|
||||
# A comment cannot go stale-green, because it never claims to be running.
|
||||
#
|
||||
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||
# ⚠️ 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
|
||||
# ------------------------------------------ -----------------------------
|
||||
@@ -56,7 +73,8 @@ def check(name, cond):
|
||||
# integrator clamp (AC 1) 6
|
||||
# integrator bound follows max_w 4
|
||||
# output clamp 3
|
||||
# slew limit 4
|
||||
# slew limit 5
|
||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||
# output freeze 2
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
@@ -328,7 +346,14 @@ def unwind(t):
|
||||
return prev
|
||||
|
||||
|
||||
tight, loose = unwind(Tuning(max_w=2000)), unwind(Tuning(max_w=2000, integrator_max_w=3000))
|
||||
# ⚠️ 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)
|
||||
@@ -372,7 +397,15 @@ def legacy(prev, grid, actual, t, sat_count):
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
diffs = []
|
||||
seen = set()
|
||||
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
|
||||
# ⚠️ `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):
|
||||
@@ -443,6 +476,205 @@ 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)}")
|
||||
|
||||
Reference in New Issue
Block a user