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
+159
View File
@@ -183,6 +183,60 @@ NOT READY banner already names the problem, (b) does not apply.
the per-unit form and main.py runs it from `run_control()`, once every
unit's own ESPHome has replied.
10. `slew_w` above the fraction of the SITE RAIL the plant can follow. [(a)]
SAFETY-39, and the first rule here whose subject is a RELATIONSHIP rather
than a threshold. `slew_w` bounds how far the site command may move in one
cycle; `control.SLEW_RAIL_FRAC` is `LOOP_TICK_S / PLANT_SETTLE_S`, so
`SLEW_RAIL_FRAC * rail` is the largest step at which the loop still takes
the plant's own measured settling time to traverse its whole output range.
Above it the controller commands its entire range before it can observe
its answer to the first step, and the stated limit - "the command moves at
most `slew_w` per cycle, so it cannot jump ahead of the inverter" - is
untrue: it jumps ahead by construction.
⚠️ THIS IS (a) AND NOT (b), and the distinction is what makes it a refusal
rather than a warning. Nothing goes inert. What happens is that a bound
the operator set means something other than what it says, and the layer
that overrules it is the PLANT - which has no status row, publishes
nothing, and cannot be read off any page in this add-on. Measured on the
owner's system 2026-09-05 11:43-11:46 at `max_w: 5000`, `slew_w: 1000`:
setpoint 380 -> 5000 in five 2 s ticks, battery -5154 W against a 5000 VA
inverter, and the grid driven from 3.4 kW of EXPORT to 2.8 kW of IMPORT.
Large (>= 900 W) setpoint steps per 30 min climbed 2 -> 1 -> 13 -> 43 that
morning. Nothing in the add-on reported anything wrong, because nothing
was: the loop followed its command exactly, and the command was wrong.
⚠️ IT IS GRADED AGAINST THE ROSTER SUM, NOT THE FLAT `max_w` AND NOT THE
WEAKEST UNIT, and this is the one rule in this file for which that is the
right rail. Rules 5, 6 and 8 are all about ONE unit - one watchdog, one
shaping claim, one pack - so they take `min()` over `unit_caps()`.
`slew_w` is not per unit: `main.py` builds `Tuning(max_w=self.site_max_w)`
and `compute()` produces the SITE target, which the allocator then splits.
So the range this bound is about is the sum, `Controller.site_max_w`, and
`site_rail_w()` below is that same number computed from options.
⚠️ AND THE `site_sign_disagree_w` NOTE IN config.yaml IS NOT A PRECEDENT
AGAINST THIS. That note says there is deliberately no rule pairing
`site_sign_disagree_w` with `max_w`, "because the bound on the SITE command
is `Controller.site_max_w`, the ROSTER SUM, and `validate_startup_config()`
is handed options rather than a roster. A rule written against max_w would
refuse a perfectly reachable two-inverter configuration." That objection is
to grading a site quantity against the FLAT key. It is answered rather than
ignored: `unit_caps()` already reads the roster the add-on will build, so
the roster sum IS available here, and this rule takes it.
⚠️ THE DIRECTION OF THIS RULE, SAID PLAINLY, BECAUSE IT IS NOT THE
DIRECTION THE TICKET ASSUMED. A rail that GROWS loosens this bound: at a
9600 W two-inverter rail, `slew_w: 1000` traverses the rail in 19.2 s and
passes, while at 5000 W the same 1000 W is the configuration measured
above. That is what the constraint says and it is correct - the bound is on
traverse TIME, and a bigger rail at the same step size takes longer to
cross. What this rule therefore protects against is the constant being left
behind when the rail moves EITHER way. It is NOT a bound on how much power
the site may command, and it does NOT bound the over-absorption: see the
note below on `gain`, which is the term that owns that and which SAFETY-39
measured and did not change.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -226,6 +280,33 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
is answered. Compare rule 5, which CAN be settled at startup because both
`sign_disagree_w` and `max_w` are config and are both known at boot.
- `gain` at any value the schema accepts, INCLUDING against a large rail.
SAFETY-39 measured this and deliberately left it alone, which is worth more
than the rule would have been. On admin/goodwe's rig, over a family of 51
export steps from 1.0 to 4.2 kW at `max_w: 5000` and a 2 s tick, the
over-absorption - peak battery power beyond what the site actually needed -
is FLAT in `slew_w`: 1233 W at slew 1000 and 1344 W at slew 250. It falls
monotonically in `gain`: 1495 W at 0.6, 961 W at 0.3, ~0 W at 0.2. The
reason is structural and is visible in control.py: `slew_w` bounds the
OUTPUT, while 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 ~2040 W per tick and reaches a 5000 W rail in under three
ticks whatever `slew_w` is.
So the 2026-08-22 derivation's own sentence - "the gain, not the slew, is
the limit by then" - is now literally true, and `gain` is the constant that
needs re-deriving next. It is NOT refused and NOT changed here: it is a
live control-law constant on a running house, changing it in the same pull
request as `slew_w` would make the next measurement uninterpretable, and a
validator rule pairing `gain` with a rail would be inventing a relationship
nobody has measured. Named here so it is a known open item rather than an
omission. See DECISIONS/SAFETY-39-slew-against-the-rail.md.
- `slew_w` BELOW the rail fraction, at any value the schema accepts. Slower
than the plant is always safe and is sometimes necessary - the live install
has run an explicit 400 W against a 5000 W rail since 2026-09-05 12:00,
which is 12.5 ticks to traverse the rail against the 9 this rule requires.
Refusing "too slow" would refuse the mitigation that is holding.
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
every ordering of the two is a legitimate site configuration. Refusing on
@@ -235,6 +316,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
@@ -392,6 +474,27 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
return caps
def site_rail_w(opts: dict) -> float | None:
"""The SITE rail: what the whole site may be commanded, in W. Rule 10's rail.
⚠️ THE SUM, NOT THE MINIMUM, AND NOT THE FLAT KEY - and it is the only place
in this file that wants a sum. Rules 5, 6 and 8 grade a threshold against
ONE unit (one watchdog, one shaping claim, one pack) and take `min()` over
`unit_caps()`. `slew_w` is not a per-unit quantity at all: `main.py` builds
`Tuning(max_w=self.site_max_w)` from the roster sum and `compute()` emits
the SITE target, which the allocator then divides. So the output range
`slew_w` is a rate across is `Controller.site_max_w`, and this is that same
number read out of options rather than off a built roster.
At the shipped roster of one this is `unit_caps()`'s single entry, which is
`max_w` exactly - byte for byte the number the single-inverter shape has
always meant. None when no entry carries a usable cap, which rule 9 has
already refused by the time rule 10 runs.
"""
caps = unit_caps(opts)
return float(sum(caps)) if caps else None
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
@@ -935,6 +1038,62 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
f"less, or raise max_w to at least {charge_w:.0f} W.")
# --- 10. a slew rate the plant can actually follow -----------------------
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
# threshold, and the whole point of writing it as one is that the number it
# bounds must not be able to outlive the rail again. `control.py`'s header
# carries the derivation; the short version is that `slew_w: 1000` was
# derived against a 1500 W clamp and the live install raised the clamp to
# 5000 without revisiting it, so the loop traversed its whole output range
# in 10 s against a plant measured at ~18 s.
#
# ⚠️ `slew_w: 0` IS NOT GRADED AND MUST NOT BE. Zero means "follow the
# rail" - `control.slew_limit_w()` resolves it to `SLEW_RAIL_FRAC * max_w`,
# which satisfies this rule by construction at every rail there is. It is
# the shipped default, and a rule that refused it would refuse the fix.
#
# ⚠️ STRICTLY `>`, MATCHING RULE 6 RATHER THAN RULE 5. Equality is the
# derived value itself: at `slew_w == SLEW_RAIL_FRAC * rail` the loop takes
# exactly `PLANT_SETTLE_S` to cross its range, which is the constraint
# satisfied, not violated. Refusing it would refuse the value this rule
# exists to recommend - and `slew_limit_w()` produces exactly that value
# for every default install, so `>=` would refuse the shipped default
# through a float comparison. A tolerance is applied for the same reason:
# `SLEW_RAIL_FRAC * 5000` is 555.55..., and an operator who writes the 556
# a status page rounded to is one watt over a boundary that is not
# meaningful to one watt.
slew_w = _number(opts, "slew_w", 0.0)
rail = site_rail_w(opts)
if slew_w is None:
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
elif slew_w < 0:
problems.append(
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
elif slew_w and rail is not None and rail > 0:
bound = SLEW_RAIL_FRAC * rail
if slew_w > bound + 1.0:
ticks = rail / slew_w
problems.append(
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
f"so the loop crosses its whole output range in {ticks:.1f} "
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
f"tick this site was measured running - while the plant takes "
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
"1.78 kW step). The controller would command its entire range "
"before it could observe its answer to the first step, which is "
"not a slow loop, it is a loop that MANUFACTURES the error it "
"then chases: measured on this system 2026-09-05, a 3.4 kW "
"export drove the setpoint 380 -> 5000 W 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. The stated limit - the "
"command moves at most slew_w per cycle, so it cannot jump "
"ahead of the inverter - is untrue at this rail; THE PLANT "
f"WINS, and it has no status row. Set slew_w to 0, which means "
f"'follow the rail' and resolves to {bound:.0f} W here and "
"moves on its own if the rail does, or write a value at or "
f"below {bound:.0f} W. Lower is always accepted.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))