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:
@@ -18,9 +18,10 @@ import asyncio
|
||||
import sys
|
||||
|
||||
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
||||
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
||||
actuator_writers, refuse, validate_startup_config,
|
||||
actuator_writers, refuse, site_rail_w, validate_startup_config,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -457,6 +458,149 @@ check("arbiter: the drain SET is clamped, not contradicted",
|
||||
check("...and the clamp names itself, which is why it is not refused",
|
||||
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("10. a slew rate the plant can actually follow (SAFETY-39)")
|
||||
# ⚠️ THIS RULE GRADES A RELATIONSHIP, WHICH IS THE POINT OF IT. Every other rule
|
||||
# in this file compares a configured number against another configured number
|
||||
# and refuses one ordering. This one refuses a RATIO: `slew_w` may not exceed
|
||||
# `SLEW_RAIL_FRAC * rail`, so the same `slew_w` is fine at one rating and
|
||||
# refused at another, and raising the rating moves the bound with it instead of
|
||||
# leaving the constant behind. That is the failure the ticket is about -
|
||||
# `slew_w: 1000` was derived at a 1500 W clamp, the live install went to 5000,
|
||||
# and nothing anywhere said the derivation no longer held.
|
||||
print(f" SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = "
|
||||
f"{LOOP_TICK_S:.0f} s / {PLANT_SETTLE_S:.0f} s = {SLEW_RAIL_FRAC:.4f}")
|
||||
|
||||
_R5000 = base(max_w=5000, maintenance_charge_w=2000, maintenance_discharge_w=2000)
|
||||
check("the rail this rule grades is the SITE rail, and at the shipped roster of "
|
||||
"one it is `max_w` exactly - byte for byte the number the single-inverter "
|
||||
"shape has always meant",
|
||||
site_rail_w(_R5000) == 5000.0)
|
||||
|
||||
# ⭐ VERIFIED BY BREAKING IT. This is the acceptance criterion stated as a test:
|
||||
# put the 2026-09-05 pair back and the gate must go red. A rule that could not
|
||||
# be made to fire on the configuration it was written for is a rule nobody has
|
||||
# checked.
|
||||
_broken = validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000)
|
||||
print(f" restoring slew_w 1000 at max_w 5000 -> {len(_broken)} problem(s)")
|
||||
if _broken:
|
||||
print(f" {_broken[0][:160]}...")
|
||||
check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSED. "
|
||||
"It crosses the whole output range in 10 s against a plant measured at "
|
||||
"18 s, which is not a fast loop, it is a loop that commands its entire "
|
||||
"range before it can observe its answer to the first step",
|
||||
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
|
||||
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
|
||||
"crossing takes, because 'slew_w is too high' sends an operator to lower "
|
||||
"a number when raising max_w or setting 0 are both correct answers too",
|
||||
why(_broken, "site rail of 5000 W")
|
||||
and why(_broken, "crosses its whole output range in 5.0 cycles"))
|
||||
check("...and it names the plant measurement it is graded against, so the "
|
||||
"number is traceable to a step response rather than to a preference",
|
||||
why(_broken, "goodwe/NOTES.md §5.10"))
|
||||
check("...and it says what to DO, including the setting that cannot go stale",
|
||||
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
|
||||
|
||||
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
|
||||
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
|
||||
# cross its range, which is the constraint met. Rule 6 permits equality for the
|
||||
# same shape of reason and rule 5 refuses it for the opposite one; this is
|
||||
# rule 6's case, and getting it backwards would refuse the value this rule
|
||||
# exists to recommend.
|
||||
_bound = SLEW_RAIL_FRAC * 5000.0
|
||||
check(f"at exactly the derived value ({_bound:.0f} W) the config STARTS - "
|
||||
"equality is the constraint satisfied, not violated",
|
||||
validate_startup_config(dict(_R5000, slew_w=int(_bound)), -5000, 5000) == [])
|
||||
check("...and one watt of slack above it starts too, because a boundary that "
|
||||
"is not meaningful to one watt must not refuse on one watt - an operator "
|
||||
"reading 556 off a status page that rounded it is not misconfigured",
|
||||
validate_startup_config(dict(_R5000, slew_w=556), -5000, 5000) == [])
|
||||
check("...and well above it does not",
|
||||
validate_startup_config(dict(_R5000, slew_w=600), -5000, 5000) != [])
|
||||
|
||||
# SLOWER IS ALWAYS ACCEPTED, and the live install is the reason it has to be.
|
||||
check("the interim that is holding on the live system - slew_w 400 against "
|
||||
"max_w 5000, applied 2026-09-05 12:00 - STARTS. It is 12.5 ticks to cross "
|
||||
"the rail against the 9 this rule requires, and refusing 'too slow' would "
|
||||
"refuse the mitigation",
|
||||
validate_startup_config(dict(_R5000, slew_w=400), -5000, 5000) == [])
|
||||
check("...and so does the schema floor at the biggest rail, because there is no "
|
||||
"such thing as too slow here",
|
||||
validate_startup_config(dict(_R5000, slew_w=50), -5000, 5000) == [])
|
||||
|
||||
# ⚠️ 0 IS NOT GRADED AND MUST NOT BE. It means "follow the rail" and satisfies
|
||||
# this rule by construction at every rail there is; a rule that refused it would
|
||||
# refuse the fix. Checked at three ratings so that "it passes" cannot be an
|
||||
# accident of the default one.
|
||||
# ⚠️ NO SLEW PROBLEM AT ANY RATING, rather than NO PROBLEMS AT ALL. At the
|
||||
# schema floor of 100 W rules 5 and 5b fire on the shipped sign thresholds,
|
||||
# which is correct and is not this rule's business - asserting an empty list
|
||||
# would make this check fail for a reason it is not about, and somebody would
|
||||
# then "fix" it by moving a sign threshold.
|
||||
_zero_problems = {m: [p for p in validate_startup_config(
|
||||
base(max_w=m, slew_w=0,
|
||||
maintenance_charge_w=min(2000, m),
|
||||
maintenance_discharge_w=min(2000, m)),
|
||||
-abs(m), abs(m)) if "slew_w" in p]
|
||||
for m in (100, 500, 2000, 5000, 15000)}
|
||||
check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
|
||||
"must not be, because it is the shipped value and it satisfies this rule "
|
||||
f"by construction at every rail there is: {_zero_problems}",
|
||||
not any(_zero_problems.values()))
|
||||
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
|
||||
"is a magnitude, and 0 already has a meaning here",
|
||||
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
|
||||
"must not be negative"))
|
||||
|
||||
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
|
||||
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
|
||||
# `compute()` emits the SITE target. So the rail is the roster sum, and adding
|
||||
# an inverter moves this bound with no operator action and no second derivation.
|
||||
#
|
||||
# ⚠️ INVERTER B'S 4600 W IS ITS DATASHEET AND SERIAL, NOT A MEASUREMENT, AND
|
||||
# INVERTER B IS NOT ON SITE (SPIKE-04, ~2026-09-12; goodwe/NOTES.md §1.1b). It
|
||||
# is used here because what is graded is the ARITHMETIC OF THE RULE, which does
|
||||
# not depend on the number being measured - not because 9600 W is a fact about
|
||||
# this house yet.
|
||||
def _roster(a=5000, b=4600, **over):
|
||||
o = base(setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": a,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sa", "batt_entity": "sensor.ba"},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": b,
|
||||
"bms_soc_floor": 10, "soc_entity": "sensor.sb", "batt_entity": "sensor.bb"}])
|
||||
o.pop("max_w"); o.pop("soc_entity"); o.pop("batt_entity")
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
_site = site_rail_w(_roster())
|
||||
print(f" roster 5000 + 4600 (B FROM DATASHEET, NOT MEASURED) -> site rail "
|
||||
f"{_site:.0f} W, bound {SLEW_RAIL_FRAC * _site:.0f} W")
|
||||
check("the rail is the roster SUM and not the weakest unit, which is what "
|
||||
"separates this rule from 5, 6 and 8 - those grade one watchdog, one "
|
||||
"shaping claim and one pack, and this grades the range one SITE COMMAND "
|
||||
"is a rate across",
|
||||
_site == 9600.0)
|
||||
check("⭐ AND THE BOUND MOVES ON ITS OWN WHEN THE ROSTER DOES: the derived value "
|
||||
"at a 9600 W site is 1067 W with nobody re-deriving anything, which is "
|
||||
"the property that makes this a fix rather than a re-tune",
|
||||
abs(SLEW_RAIL_FRAC * _site - 9600.0 / 9.0) < 1e-9)
|
||||
check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
|
||||
"ACCEPTED at 9600 - the bound is on how long the crossing TAKES, and a "
|
||||
"bigger rail at the same step size takes longer to cross. ⚠️ This is the "
|
||||
"direction of the rule stated as a test, because it is not the direction "
|
||||
"the ticket assumed: a growing rail LOOSENS this bound. What the rule "
|
||||
"protects against is the constant being left behind when the rail moves; "
|
||||
"it is not a bound on how much the site may command, and it does not "
|
||||
"bound the over-absorption, which is a `gain` term SAFETY-39 measured "
|
||||
"and deliberately did not change",
|
||||
validate_startup_config(dict(_R5000, slew_w=1000), -5000, 5000) != []
|
||||
and validate_startup_config(_roster(slew_w=1000)) == [])
|
||||
check("...and a slew that outruns even the doubled rail is still refused there, "
|
||||
"so the roster path is not simply un-graded",
|
||||
why(validate_startup_config(_roster(slew_w=2000)),
|
||||
"site rail of 9600 W"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
||||
@@ -470,12 +614,21 @@ 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:
|
||||
_txt = _fh.read()
|
||||
# ⚠️ `slew_w` JOINED THIS SET AT SAFETY-39 AND IT IS THE INTERESTING MEMBER.
|
||||
# The others are refused when they are too big for `max_w`; this one is refused
|
||||
# when it is too big for `max_w` AS A RATIO, so shipping the wrong number here
|
||||
# would be shipping the exact defect the card is about with the gate that
|
||||
# catches it standing right beside it. Reading it out of config.yaml is what
|
||||
# makes "the shipped value is 0" a fact rather than a claim.
|
||||
_shipped = {k: float(v) for k, v in re.findall(
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all four defaults found)",
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w"
|
||||
r"|slew_w): (-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all five defaults found)",
|
||||
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||
"maintenance_discharge_w", "sign_disagree_w"})
|
||||
"maintenance_discharge_w", "sign_disagree_w", "slew_w"})
|
||||
check(f"...and the shipped slew_w is 0, i.e. FOLLOW THE RAIL - the one value "
|
||||
f"that cannot be left behind when max_w moves (got {_shipped.get('slew_w')})",
|
||||
_shipped.get("slew_w") == 0.0)
|
||||
check(f"the shipped defaults are accepted {_shipped}",
|
||||
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
||||
check("...and the shipped charge rate is at or under the shipped ceiling",
|
||||
|
||||
Reference in New Issue
Block a user