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:
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user