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:
@@ -1393,13 +1393,29 @@ def _sign_site(hass, store=None, **over):
|
||||
return c
|
||||
|
||||
|
||||
async def _drive(c, n=14, dt=0.2, watch=None):
|
||||
async def _drive(c, n=14, dt=0.2, watch=None, pre=None):
|
||||
"""Drive `n` real cycles. `watch` samples AFTER each cycle, `pre` BEFORE it.
|
||||
|
||||
⚠️ `pre` EXISTS BECAUSE `watch` ALONE CANNOT SEE WHAT A DETECTOR WAS HANDED,
|
||||
AND SAFETY-39 IS HOW THAT WAS FOUND. `cycle()` evaluates the sign watchdogs
|
||||
against THE STANDING COMMAND - the write from the PREVIOUS cycle - and then
|
||||
issues a new one. So `watch`'s `last_write` is the command AFTER the
|
||||
detector ran, and pairing it with `c.grid` builds a pair the detector never
|
||||
saw. While `slew_w` was 1000 the loop was at its rail within two cycles and
|
||||
consecutive commands were IDENTICAL, so the off-by-one was invisible; at a
|
||||
rail-following slew the loop ramps and the two numbers differ by one step.
|
||||
That is a fault in this harness, not in the code it grades - and a check
|
||||
that compares an operator-facing sentence against the wrong cycle is
|
||||
precisely the "the data could not reach the failure the text names" defect
|
||||
MUTATION.md keeps finding. `pre` reads the operands the detector will use.
|
||||
"""
|
||||
seen = []
|
||||
for _ in range(n):
|
||||
await c.read_inputs()
|
||||
before = {} if pre is None else pre(c)
|
||||
await c.cycle()
|
||||
if watch is not None:
|
||||
seen.append(watch(c))
|
||||
seen.append({**before, **watch(c)})
|
||||
await asyncio.sleep(dt)
|
||||
return seen
|
||||
|
||||
@@ -1812,12 +1828,19 @@ async def _n1_sign():
|
||||
# `_n1_calls` records what was HANDED to a detector, so grading the banner
|
||||
# against it would compare the swap against itself - the DEV-02 defect.
|
||||
# `last_write` and `c.grid` are read off the objects the plant moved.
|
||||
seen = await _drive(c, n=12, dt=0.2, watch=lambda c: dict(
|
||||
grid=c.grid,
|
||||
# ⚠️ `stood` COMES FROM `pre`, i.e. BEFORE `cycle()` RUNS (SAFETY-39). It is
|
||||
# the STANDING command the sign watchdog is evaluated against; taking it
|
||||
# after the cycle would pair this cycle's meter with next cycle's command
|
||||
# and grade the banner against a pair that never existed. See `_drive`.
|
||||
# `n` is 24 rather than 12 because at the shipped rail-following `slew_w`
|
||||
# the loop needs nine cycles to reach a 2000 W rail instead of two.
|
||||
seen = await _drive(c, n=24, dt=0.2, pre=lambda c: dict(
|
||||
stood=(c.inverters[0].last_write
|
||||
if c.inverters[0].last_write is not None
|
||||
else c.inverters[0].target),
|
||||
tripped=c.inverters[0].sign_wd.tripped))
|
||||
else c.inverters[0].target)),
|
||||
watch=lambda c: dict(
|
||||
grid=c.grid,
|
||||
tripped=c.inverters[0].sign_wd.tripped))
|
||||
return c, _n1_calls, seen
|
||||
|
||||
|
||||
@@ -1839,12 +1862,24 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
|
||||
"is what an operator reads, what the action log carries and what is "
|
||||
"persisted, so it is graded on the literal",
|
||||
"against a meter reading" in _inv1.sign_wd.reason)
|
||||
# The last cycle before the latch, named rather than indexed: afterwards the
|
||||
# THE CYCLE THE LATCH HAPPENED ON, named rather than indexed: afterwards the
|
||||
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
|
||||
# remedy. Both operands come off the controller, not off the fixture's options.
|
||||
_pre1s = [x for x in _seen1s if not x["tripped"]]
|
||||
_mid1s = _pre1s[-1]
|
||||
print(f" last cycle before the latch: stood {_mid1s['stood']:+.0f} W, "
|
||||
#
|
||||
# ⚠️ IT IS THE FIRST *TRIPPED* SAMPLE, AND IT USED TO BE THE LAST UN-TRIPPED ONE
|
||||
# (SAFETY-39). The detector is evaluated INSIDE `cycle()` against the STANDING
|
||||
# command - the write from the previous cycle - and against the meter read at
|
||||
# the top of THIS one, and it latches on the cycle whose pair crosses the hold.
|
||||
# So the operands the sentence quotes belong to the LATCHING cycle, not to the
|
||||
# one before it. `stood` is now sampled by `_drive`'s `pre` hook, before
|
||||
# `cycle()` overwrites it with the failsafe's 0 W, which is what makes that
|
||||
# cycle readable at all - and it is why the old form had to reach back a cycle.
|
||||
# The two forms agreed while `slew_w` was 1000 and the loop sat at its rail with
|
||||
# consecutive commands IDENTICAL; at a rail-following slew the loop ramps and
|
||||
# they differ by exactly one step, which is how this was found.
|
||||
_first1s = [x for x in _seen1s if x["tripped"]]
|
||||
_mid1s = _first1s[0]
|
||||
print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, "
|
||||
f"meter {_mid1s['grid']:+.0f} W")
|
||||
print(f" {_inv1.sign_wd.reason!r}")
|
||||
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
|
||||
@@ -2529,7 +2564,14 @@ def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
|
||||
return c
|
||||
|
||||
|
||||
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8):
|
||||
# ⚠️ `n` IS 20 AND WAS 8 (SAFETY-39). The comment on `_s38_frozen_ctrl` says
|
||||
# gain 0.05 is there "to buy a ramp" so that the SAME run carries both a
|
||||
# below-the-rail and an at-the-rail answer. At the shipped rail-following
|
||||
# `slew_w` the ramp is now the SLEW - 55 W a cycle against this 500 W site,
|
||||
# where gain 0.05 alone gave 125 - so eight cycles no longer reach the rail and
|
||||
# the run carried only the counterfactual. The fixture needs the cycles, not a
|
||||
# different tuning: the point is a run that crosses the rail part way through.
|
||||
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=20):
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, ratings)
|
||||
_s38_seed_quarter(c, meter_w)
|
||||
@@ -2604,7 +2646,7 @@ async def _s38_one():
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, _S38_ONE)
|
||||
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
|
||||
for _ in range(8):
|
||||
for _ in range(20): # SAFETY-39: the slew is the ramp now - see _s38_frozen
|
||||
_s38_feed(c, _S38_FROZEN_METER_W)
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
|
||||
Reference in New Issue
Block a user