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,12 +18,92 @@ exists because its absence produced a specific, observed failure.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-39. THE ONE RELATIONSHIP IN THIS FILE THAT IS NOT A NUMBER.
|
||||
#
|
||||
# ⚠️ WHAT WENT WRONG, because the shape of it is the reason this is a ratio and
|
||||
# not a constant. `slew_w: 1000` was derived on 2026-08-22 against a clamp of
|
||||
# 1500 W (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"),
|
||||
# and its stated justification was that it "puts most of the correction in the
|
||||
# first cycle WHILE STILL CAPPING HOW FAR THE LOOP CAN JUMP AHEAD OF THE
|
||||
# INVERTER". At 1500 W that sentence was true: 1000 W/cycle reaches the rail in
|
||||
# two cycles and the rail itself bounds the excursion.
|
||||
#
|
||||
# The rail then moved to 5000 W on the live install and the constant did not.
|
||||
# 1000 W per 2 s tick is 500 W/s, so the loop traverses its WHOLE OUTPUT RANGE
|
||||
# in 10 s against a plant NOTES.md §5.10 measured at ~18 s up / ~23 s down for a
|
||||
# 1.78 kW step. It commands its entire range before it can observe its answer to
|
||||
# the first step. Measured on the owner's system 2026-09-05 11:43-11:46: a real
|
||||
# 3.4 kW export, the setpoint ramping 380 -> 5000 in five ticks, the battery at
|
||||
# -5154 W against a 5000 VA inverter, and the grid pushed from 3.4 kW of EXPORT
|
||||
# to 2.8 kW of IMPORT - the loop manufactured the error it then chased.
|
||||
#
|
||||
# ⚠️ SO THE FIX IS NOT A NEW NUMBER. A number is what went stale. What is
|
||||
# written here is the RELATIONSHIP the 2026-08-22 sentence was really asserting:
|
||||
#
|
||||
# THE LOOP MUST NOT TRAVERSE ITS FULL OUTPUT RANGE FASTER THAN THE PLANT
|
||||
# TAKES TO SETTLE.
|
||||
#
|
||||
# slew_w <= max_w * LOOP_TICK_S / PLANT_SETTLE_S
|
||||
#
|
||||
# `max_w` here is the SITE rail (main.py builds `Tuning(max_w=site_max_w)`, the
|
||||
# roster sum), so the bound moves with the roster and a second inverter cannot
|
||||
# re-open this by arriving. validate.py rule 10 refuses a configuration that
|
||||
# breaks it; `slew_w = 0` below means "follow the rail" and is the shipped
|
||||
# default, exactly as `integrator_max_w = None` already means "follow max_w".
|
||||
#
|
||||
# ⚠️ BOTH NUMBERS ARE WRITTEN OUT RATHER THAN THEIR QUOTIENT, AND THAT IS NOT
|
||||
# TIDINESS. The quotient is only meaningful in the regime the two were measured
|
||||
# in, and one of them is NOT a property of this code:
|
||||
#
|
||||
# PLANT_SETTLE_S is the inverter and the pack. goodwe/NOTES.md §5.10,
|
||||
# 2026-08-22, a 1.78 kW kettle step driven from HA with a
|
||||
# plug that meters its own draw: ~18 s from the step to the
|
||||
# deadband going up, ~23 s coming down. 18 is taken because
|
||||
# it is the SHORTER of the two and therefore the LOOSER
|
||||
# bound - this is a refusal boundary, and rules 5 and 6 in
|
||||
# validate.py refuse the impossible case only, never "close
|
||||
# to the rail".
|
||||
#
|
||||
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
|
||||
# a changed meter value or on `cycle_floor_s`, whichever comes
|
||||
# first, so the tick is the meter's own update rate. 2.0 s is
|
||||
# what the live install measured on 2026-09-05 (the setpoint
|
||||
# stepped +1000 W every 2 s), and it is the fastest meter this
|
||||
# project has ever run.
|
||||
#
|
||||
# ⚠️ AND THIS ONE IS NOT WRONG IN THE SAFE DIRECTION, WHICH IS WHY IT IS SAID
|
||||
# HERE. A SLOWER meter makes this bound conservative - it refuses some
|
||||
# configurations that would have been fine, which is visible immediately and
|
||||
# costs one edit. A meter FASTER than 2 s makes it loose by exactly that ratio,
|
||||
# and nothing in this add-on would say so. The tick cannot go below 1 s
|
||||
# (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), so the
|
||||
# worst case is a factor of two - but if a faster meter is ever fitted, this
|
||||
# constant has to be RE-DERIVED and not merely re-read. Compare
|
||||
# validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
|
||||
# only ever be wrong the other way.
|
||||
PLANT_SETTLE_S = 18.0
|
||||
LOOP_TICK_S = 2.0
|
||||
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
gain: float = 0.6
|
||||
max_w: float = 2000.0
|
||||
slew_w: float = 1000.0
|
||||
# SAFETY-39. How far the command may move in one cycle. ⚠️ 0 MEANS "FOLLOW
|
||||
# THE RAIL" - `SLEW_RAIL_FRAC * max_w` - and it is the default and the
|
||||
# recommended setting, for the reason written at the top of this file: a
|
||||
# fixed number here is a constant that outlives the rail it was derived
|
||||
# against, and that is the defect measured on the live system on
|
||||
# 2026-09-05. Read `slew_limit_w()`, never this field.
|
||||
#
|
||||
# It stays settable, because slower than the rail implies is a legitimate
|
||||
# and sometimes necessary choice - the live install has run an explicit
|
||||
# 400 W since 2026-09-05 12:00 - and because `slew_w` is an option an
|
||||
# operator already has. FASTER than the rail implies is what validate.py
|
||||
# rule 10 refuses.
|
||||
slew_w: float = 0.0
|
||||
deadband_w: float = 15.0
|
||||
step_w: int = 10
|
||||
saturation_w: float = 500.0
|
||||
@@ -83,6 +163,28 @@ class Decision:
|
||||
clamped: bool = False
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
"""How far the command may move this cycle, in W. Always positive.
|
||||
|
||||
⚠️ THE ONE READER OF `Tuning.slew_w`, and everything else must come through
|
||||
here. `slew_w = 0` means "follow the rail" and resolves to
|
||||
`SLEW_RAIL_FRAC * max_w`, which is the whole of SAFETY-39: the limit is a
|
||||
RATIO to the rail, so raising `max_w` moves it and cannot leave it behind.
|
||||
A caller that read the field directly would see 0 and apply no limit at all
|
||||
- the exact opposite of the setting's meaning - which is why this exists as
|
||||
a function rather than as a comment telling people to remember.
|
||||
|
||||
⚠️ AND IT IS NOT CLAMPED TO THE BOUND. An explicit `slew_w` above the rail
|
||||
fraction is refused at STARTUP by validate.py rule 10, not silently reduced
|
||||
here. Silently reducing it is the failure this whole module's docstring is
|
||||
about - an operator's stated number quietly meaning something else - and
|
||||
validate.py's own docstring calls it case (a).
|
||||
"""
|
||||
if tuning.slew_w:
|
||||
return abs(float(tuning.slew_w))
|
||||
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
|
||||
|
||||
|
||||
def compute(
|
||||
prev_w: float,
|
||||
grid_w: float,
|
||||
@@ -231,7 +333,8 @@ def compute(
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||
slew = slew_limit_w(tuning)
|
||||
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
target = slewed
|
||||
|
||||
Reference in New Issue
Block a user