SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
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:
2026-09-06 01:10:15 +02:00
co-authored by Claude Opus 5
parent 24c3d87d60
commit 5d7e275df4
21 changed files with 1701 additions and 46 deletions
+44 -2
View File
@@ -516,7 +516,40 @@ options:
# ==========================================================================
auto_start: false
gain: 0.6
slew_w: 1000
# ⚠️ SAFETY-39. 0 IS NOT "UNSET" AND IT IS NOT "NO LIMIT" - IT IS "FOLLOW THE
# RAIL", and it is the shipped value. `control.slew_limit_w()` resolves it to
# `SLEW_RAIL_FRAC * max_w`, which is 222 W against the 2000 W default here,
# 556 W against the live install's 5000 W, and 1067 W against the ~9600 W a
# second inverter makes of the site. `integrator_max_w: 0` two lines down has
# meant exactly this since it was added; this key now means it too.
#
# ⚠️ WHY IT IS A RATIO AND NOT A NUMBER, WHICH IS THE WHOLE OF THIS CARD. It
# WAS a number: 1000, derived 2026-08-22 against a clamp of 1500 W
# (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)") on the
# grounds that it caps "how far the loop can jump ahead of the inverter". The
# rail then went to 5000 on the live install and the constant did not move, so
# 1000 W per 2 s tick crossed the whole output range in 10 s against a plant
# NOTES.md §5.10 measured at ~18 s up / ~23 s down. Measured on the owner's
# system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000
# in five 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. Large (>= 900 W) steps per 30 min
# went 2 -> 1 -> 13 -> 43 that morning. Nothing was broken; the rail moved.
#
# An explicit number is still accepted and is not clamped - the live install
# has run 400 since 2026-09-05 12:00, which is slower than the rail implies
# and is fine. What validate.py rule 10 refuses is a number FASTER than the
# rail implies, because at that setting the stated limit is not the limit and
# the layer that overrules it is the plant, which has no status row.
#
# ⚠️ AND THE COST IS REAL AND IS NOT HIDDEN: following the rail is slower to
# converge than 1000 was. Measured over 51 export steps on the in-repo model
# (test_control.py) the largest tick step falls from 1000 W to 556 W and the
# >= 900 W step disappears from every run - but the OVER-ABSORPTION does not
# move, because that is a `gain` term and not a slew term. See
# DECISIONS/SAFETY-39-slew-against-the-rail.md, which says so at length rather
# than letting this card be read as more than it is.
slew_w: 0
deadband_w: 15
target_grid_w: -10
step_w: 10
@@ -744,7 +777,16 @@ schema:
# ==========================================================================
auto_start: bool
gain: float(0.05,1.0)
slew_w: int(50,5000)
# ⚠️ THE FLOOR IS 0, NOT 50, AND 0 IS THE SHIPPED VALUE - see the options block
# above. It means "follow max_w", so the range starts at the setting that
# cannot go stale rather than at the smallest number somebody could type.
# ⚠️ THE CEILING IS STILL 5000 AND IS STILL NOT THE BOUND THAT MATTERS. The
# binding one is `SLEW_RAIL_FRAC * max_w`, a relation BETWEEN keys, and this
# grammar cannot express a relation - which is the same reason
# `maintenance_charge_w`'s ceiling is not its real bound either (see the note
# above it). validate.py rule 10 is where it is written.
slew_w: int(0,5000)
deadband_w: int(0,500)
target_grid_w: float(-200,200)
step_w: int(1,100)