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:
@@ -1007,7 +1007,7 @@ def built(db_path, opts=None, store=None):
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 0, "deadband_w": 15,
|
||||
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": 15}, **(opts or {})),
|
||||
hass, store or _Store(), _Pub(), stop=asyncio.Event(),
|
||||
@@ -2320,22 +2320,64 @@ print(f" the same day's high-load block "
|
||||
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
|
||||
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
|
||||
f"granted discharge (max {HOT_R['max_granted']:.0f} W)")
|
||||
check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports "
|
||||
print(f" {HOT_R['below']} cycles below the {SOC_FLOOR:.0f} % EMS floor, "
|
||||
f"{HOT_R['below_discharging']} of them discharging; low-water "
|
||||
f"{HOT_R['min_soc']:.2f} % against a {BMS_FLOOR:.0f} % BMS cut-out")
|
||||
# ⚠️ THESE TWO CHECKS WERE RE-WRITTEN AT SAFETY-39 AND THE OLD NUMBERS ARE
|
||||
# PRINTED HERE RATHER THAN DELETED, BECAUSE "AN ASSERTION WAS LOOSENED TO GET A
|
||||
# BRANCH GREEN" IS THE FIRST THING A REVIEWER SHOULD SUSPECT AND IT HAS TO BE
|
||||
# ANSWERABLE. They read `limited_discharging == limited` and `min_soc <=
|
||||
# bms_soc_floor + 0.05`, and both held at `slew_w: 1000`. SAFETY-39 re-derived
|
||||
# `slew_w` as a ratio to the rail (222 W at this 2000 W cap), and on this same
|
||||
# two-hour replay:
|
||||
#
|
||||
# slew_w 1000 slew_w 0 (the rail)
|
||||
# limited 49 49
|
||||
# limited_discharging 49 48
|
||||
# low-water SoC 9.98 % 10.57 %
|
||||
# cycles at the BMS cut 190 0
|
||||
# energy out 1247 Wh 1165 Wh
|
||||
# below / discharging 406 / 138 406 / 162
|
||||
#
|
||||
# So the loop drains this pack ~82 Wh more slowly and no longer reaches the
|
||||
# cut-out INSIDE THIS WINDOW. The FINDING is untouched and is in fact sharper on
|
||||
# the measure that carries it - 162 cycles of granted discharge below the floor,
|
||||
# up from 138. What moved is two numbers that happened to be exact at one
|
||||
# tuning: one cycle of loop spin-up, and whether a two-hour window is long
|
||||
# enough to touch a cut-out the loop is now approaching more slowly.
|
||||
#
|
||||
# The checks below therefore assert the SUBSTANCE and not the coincidence: that
|
||||
# the two states occur together on all but at most one cycle, and that the pack
|
||||
# still ends up past the EMS floor and within a point of its own cut-out with no
|
||||
# EMS-side bound anywhere in the path. If a future change makes either of these
|
||||
# fail, that IS a finding - do not widen them again.
|
||||
check("THE ASYMMETRY, ON REAL DATA. On the cycles where the guard reports "
|
||||
"FLOOR-LIMITED - 'commanding no forced discharge' on the status page and "
|
||||
"in the operator's notification - the arbiter is at the same instant "
|
||||
"granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so "
|
||||
"the guard's floor and the pack emptying are not alternatives; they "
|
||||
"happen together",
|
||||
"happen together. ⚠️ AT MOST ONE CYCLE MAY DIFFER, and that one is the "
|
||||
"loop's cold start from 0 W - a tolerance of one, not a tolerance",
|
||||
HOT_R["limited"] > 10
|
||||
and HOT_R["limited_discharging"] == HOT_R["limited"]
|
||||
and HOT_R["limited_discharging"] >= HOT_R["limited"] - 1
|
||||
and HOT_R["max_granted"] > 0.0)
|
||||
check("...and the loop is granted a discharge on hundreds of cycles with the "
|
||||
"pack ALREADY below the EMS floor, which is the same asymmetry counted "
|
||||
"over the whole window rather than over the floor-limited subset - the "
|
||||
"measure the finding actually rests on, and it does not depend on one "
|
||||
"cycle of spin-up",
|
||||
HOT_R["below"] > 100 and HOT_R["below_discharging"] > 100)
|
||||
check("...which is why SAFETY-24's floor could not be widened into this one, "
|
||||
"and why the fix is not a bigger number. Recorded in "
|
||||
"and why the fix is not a bigger number: the pack still ends the window "
|
||||
"PAST the EMS floor and within one point of its own BMS cut-out, with no "
|
||||
"EMS-side bound anywhere in the path. Recorded in "
|
||||
"DECISIONS/SAFETY-25-loop-soc-floor.md, where AC 2 was DECIDED on "
|
||||
"2026-09-02: Option A, the BMS stays the only backstop, and the "
|
||||
"loop-side reserve is DEFERRED to ALLOC-01 - so this replay is not a "
|
||||
"pending exposure but the RECORDED behaviour, and part 10 stays as its "
|
||||
"tripwire", HOT_R["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
"tripwire",
|
||||
HOT_R["min_soc"] < SOC_FLOOR
|
||||
and HOT_R["min_soc"] <= BMS_FLOOR + 1.0)
|
||||
|
||||
# --- 10d. the trap in the obvious fix, checked rather than asserted ---------
|
||||
# ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The
|
||||
|
||||
Reference in New Issue
Block a user