Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-48-bounding-the-integrator-lead.md
T
glenn schrooyenandClaude Opus 5 a6af332048
checks / unit (pull_request) Successful in 7m55s
SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
Four review findings, none of them in executable code. The control law, the
tests and the instruments' arithmetic are untouched; every number below was
re-measured against the committed 16-day capture before it was written down.

R1 - app/control.py carried a justification the decision record and MUTATION.md
habit 22 had already retracted, and it was false about the code it sits in. It
said an unconditional placement "would also pull the integrator toward a
command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The
118 was measured against a different pair; on the pair the sentence names the
two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow
form that shipped DOES move a saturation-frozen integrator: it runs before the
freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap
changes `i_w` on 1095, and on 679 of those it moves it further out on the side
it was already on - putting back magnitude the freeze had just let fall, which
the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid
+4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at
-176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal
(the freeze clipped on 75 512 ticks, the slew bound on none of them), and that
is stated too. It is also where SAFETY-13's settle values went: gating the cap
on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part
4(b) does not come back - that one moved for the winding reason.

R3 - the record read its own sensitivity table backwards. `fit_plant()` picks
lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while
the tool's own printed fidelity measure (export against the recorded 29.43 kWh)
prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s
release/1.0's worst quarter is ALREADY 3434 W and this card changes it by
exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a
carries the table; the export reduction now leads every summary and the peak is
presented as lag-dependent. The form-A rejection is strengthened rather than
weakened: form A is worse on the billed quarter at every lag where release/1.0
produces a peak at all, and at every top-N cut of the quarters.

R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads
with the export reduction, says the figures are simulated, and says plainly
that no capacity-tariff improvement is promised.

R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and
at the slowest slews it is slightly WORSE" while printing a monotonically
falling column. That sentence is TRUE on release/1.0 (reproduced:
958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law
(777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its
record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out
of the law it imported and narrates from the column it just printed. Both
branches of that narration were driven.

R5 - three counts in the L table were decoration and are re-driven in the CI
image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the
function names exchanged and 5+4 with the branch bodies exchanged (two
mutations wearing one name, now listed separately), L6 is 6+3. The separable-
state count is grid-dependent and three sweeps disagree, so all three are
quoted with their grids and the load-bearing claim is WHERE they separate.

R7 - "a large import answered too weakly" is not what the aggregate shows. Form
A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers
8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm
is a tail redistribution inside quarters that already net to offtake. The
conclusion stands; the sentence is narrowed to it.

Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at
a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:37:01 +02:00

33 KiB
Raw Blame History

SAFETY-48 — the slew step ceiling reaches the integrator, and what it bounds is the LEAD

Status: decided and implemented on branch SAFETY-48, on top of TEL-10. Card: SAFETY-48 — "the integrator goes on winding while the command is slew-limited". Nothing here reached the inverter. The live install still runs slew_w: 400 applied by hand on 2026-09-05 12:00 and release/1.0's control law. This branch does not deploy and does not bump the version.


0. The four things a reader should take away

  1. It is a missing case, not a wrong constant, and the arithmetic says so. The integrator moves gain * error a cycle and the command moves at most slew_w, so the integrator outruns the command whenever |error| > slew_w / gain — 667 W at the live shape. SAFETY-39's retune made slew_w smaller, which makes that threshold lower. §1.
  2. ⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, AND THAT IS WHY IT IS NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag cuts export 13 % and moves the worst quarter-hour average offtake from 3739 W to 3935 W, with the mean quarter +2.7 W — and on the billed quarter it is worse at every plant lag where release/1.0 produces a peak at all (a tie at lag 0, worse at 2.5 / 5.0 / 7.5 / 10.0 s) and at every top-N cut of the quarters, which makes this the least lag-sensitive result in the card. The harm is a tail redistribution inside quarters that already net to offtake, not a weaker answer to import in aggregate. §3, §3a.
  3. What ships bounds the LEAD at one slew step, only where the slew is what stopped the command, and only in the leading direction. The robust result is the export reduction — 39.6 → 34.1 kWh, and it falls at every plant and tuning tried — with wasted throughput 4.67 → 3.19 kWh, sign reversals 2992 → 2770 and median recovery 232 → 199 s. ⚠️ The billed quarter is not something this card can claim. It reads 3739 → 3434 W at the fitted plant lag of 7.5 s; at every lag ≤ 5.0 s — including the one that best reproduces the recorded export — release/1.0 is already at 3434 W and this card moves it by exactly zero. The lag is not identified by the data. §4, §3a.
  4. Two committed numbers moved and neither is a silent edit. SAFETY-13's released command settles lower, because there is less wound integrator to settle onto; and SAFETY-39's "the slew does not own the over-absorption" is now false — inverted, on its own independent plant model. §5, §6.

1. The defect, in the terms the code uses

compute() freezes the integrator with an asymmetric rule, and the comment beside it states the requirement outright:

the requirement is that the INTEGRATOR stop accumulating, not only the command

But frozen is computed from one signal:

saturated_now = abs(prev_w - actual_w) > tuning.saturation_w   # not tracking

Slew-limiting never enters it. When the command hits the step ceiling the integrator keeps winding at gain * error until it reaches integrator_max_w, and the unwind afterwards is integrator_max_w / slew_w cycles — 12.5 at the live shape, about a minute at the meter's measured tick.

⚠️ AND THE RETUNE MADE IT MORE FREQUENT, NOT LESS. |error| > slew_w / gain is 1667 W at slew_w: 1000 and 667 W at slew_w: 400. The measured median grid error at the onset of a recorded episode is 1114 W — above the second threshold and below the first. This is why SAFETY-39 did not fix it, and the mechanism is the same one §6 shows inverting.

2. How it was measured, and what the measurement cannot say

tools/replay_recorded_loop.py, over 16.00 days / 256 252 meter instants of the live install's own recorder, exported read-only.

Real: the disturbance (D = grid + batt at every recorded meter instant — the house with this loop's own battery taken back out, which is exogenous and therefore valid to drive a counterfactual with); the tick (the meter's own recorded instants, median 5.01 s); and the control law, imported out of the checkout. The "before" law is the same file with the SAFETY-48 block removed by a named substitution and exec'd into a fresh namespace — never a transcription — and a missing or duplicated anchor is a refusal with NOTHING HAS BEEN MEASURED on it, because an instrument that quietly compared the shipped law with itself would report the card as changing nothing.

Modelled: the inverter (pure delay 7.5 s + first-order lag 2.0 s + batt = 0.973 × cmd − 22.4 W, fitted on this same recording above the SoC floor, residual 375 W RMS); the pack (SoC integrated, discharge refused at or below the floor); and everything else the arbiter does — the recorded setpoint is the arbiter's output after the peak guard, the SoC clamps, maintenance and arbitrage, while the replay runs compute() alone.

⚠️ THE ABSOLUTE FIGURES ARE PESSIMISTIC BY ABOUT A THIRD AND THE TOOL PRINTS IT ON EVERY RUN. Replaying the before-law reproduces 39.6 kWh of export where the install actually exported 29.4 kWh over the same window. The delta is what this instrument is for: both laws run on the same ticks, the same disturbance, the same plant and the same seed.

⚠️ THE SoC FILTER ON THE PLANT FIT IS THE WHOLE FIT, AND IT IS A FINDING IN ITS OWN RIGHT. This install spends 36 % of its ticks at or below a 10 % SoC with the command standing near 2 kW and the battery delivering nothing. Fitted over all ticks the recording says batt = 0.50 × cmd − 396 W, r = 0.70 — a plant that follows half the command, which no inverter does. Above the floor the same data says batt = 1.00 × cmd − 28 W, r = 0.977. The floor is the PACK and is modelled separately.

3. The form the card asked for, and the number that refused it

The card asked for the asymmetric rule already in service for saturation, on a flag carried across cycles the way sat_count is. It was built — from release/1.0's own source, one substitution per change — and measured on the same 16 days through the same pipeline:

release/1.0 form A — carried slew flag + the asymmetric freeze SAFETY-48 — the lead cap
export kWh 39.61 34.43 34.07
import kWh 116.07 110.62 111.20
battery sign reversals 2992 2656 2770
wasted throughput kWh 4.67 2.99 3.19
worst 15-min offtake W 3739 ⚠️ 3935 3434
mean quarter-hour offtake delta — ⚠️ +2.73 W −2.39 W
worst quarter-hour delta — +992 W +532 W
recovery median s 232 184 199
recovery p99 s 849 781 850
recovery max s 901 890 898
unrecovered (> 900 s) 53 26 31
|i_w − prev_w| p99 W 3361 1534 800
|i_w − prev_w| max W 9200 6912 800

Form A wins on four rows and loses on the one that is billed. The capacity tariff (capaciteitstarief) bills the month's worst quarter-hour average offtake, and form A raises it by 196 W — and raises the MEAN quarter by 2.7 W, so this is systematic and not one unlucky quarter. It does so in five of the six plant and tuning variants in --sensitivity; the sixth is a tie.

The per-cycle mechanism is that a frozen integrator stops at prev + gain·error(first cycle), which is (1 − gain) short of what the site needs — 40 % short at gain: 0.6.

⚠️ AND THE SENTENCE THAT USED TO FOLLOW THAT ONE WAS TOO BIG FOR THE DATA. It read "for an IMPORT it is offtake, so a large import is answered too weakly for as long as the ramp lasts". The aggregate does not show a weaker answer to import: form A cuts total import by 5.45 kWh and export by 5.18 kWh against release/1.0, and paired tick by tick it delivers 8.56 kWh LESS offtake energy on the ticks release/1.0 spent importing (lower on 38 229 such ticks, higher on 19 669). What form A moves is the TAIL, within quarters that already net to offtake — the worst quarter goes 3739 → 3935 W, the top five 3260 → 3300 W, the top fifty 1880.0 → 1882.7 W. The capacity tariff bills the worst quarter and nothing else, so a redistribution that leaves the mean alone and lifts the maximum is exactly the trade that is refused here. The conclusion is unchanged; the causal sentence is now the one the data supports.

⚠️ THE CARD SAID "do not invent a second rule", AND THE MEASUREMENT SAYS TO. That instruction is recorded here rather than quietly dropped. What ships is a different rule; the paragraph above is why.

3a. ⚠️ The plant lag is NOT identified by this recording, and the billed quarter is the claim that depends on it

fit_plant() picks lag 7.5 s because it minimises battery-power RMS. That objective barely distinguishes the candidates — 375.3 W at 7.5 s against 376.4 W at 2.5 s, 0.3 % — while the tool's own printed fidelity measure, replayed export against the 29.43 kWh this install actually exported, prefers 2.5 s by the whole of the 35 % gap the header warns about. Two measures on the same data disagree, and nothing here breaks the tie.

So the sweep has to be read as a sensitivity, not as a confirmation:

plant lag fit RMS W replayed export kWh (release/1.0) worst 15-min offtake W: release/1.0 form A SAFETY-48 mean quarter ΔW
0.0 392.3 23.5 3434 3434 3434 +0.53
2.5 376.4 28.9 ← nearest 29.43 3434 ⚠️ 3732 3434 −0.59
5.0 378.0 32.1 3434 ⚠️ 3711 3434 −1.02
7.5 375.3 ← chosen 39.6 3739 ⚠️ 3935 3434 −2.39
10.0 384.2 75.4 3954 ⚠️ 4326 3434 −4.65

(The RMS column is fit_plant()'s own inner sweep pinned to one lag — best tau and a re-fitted gain/offset at that lag. The export and quarter columns follow --sensitivity's convention instead: only the lag is substituted into the global fit, everything else held. That is why the lag 5.0 and 10.0 export figures are the ones already printed in §7b.)

⚠️ AT EVERY LAG ≤ 5.0 s THE IMPROVEMENT IS NOT AN IMPROVEMENT — IT IS AN ABSENCE. release/1.0's worst quarter is already 3434 W there and SAFETY-48 changes it by exactly zero. §7b's paragraph used to read the 3434 → 3434 rows as "the direction does not move"; that is the wrong reading. The direction has nothing to move: at those plants release/1.0 never produces a quarter worse than 3434 W in the first place, and the 3739 → 3434 W headline exists only at the single longest lag fit_plant() happens to select. The peak claim is a property of the plant model, not of the card. What the card can claim on the billed quantity is that it does not make it worse, at any lag.

⚠️ AND THE ONE RESULT HERE THAT IS NOT LAG-SENSITIVE IS THE FORM-A REJECTION. Form A's worst quarter is the worst of the three at every lag where release/1.0 produces a peak at all — 3732, 3711, 3935, 4326 against release/1.0's 3434, 3434, 3739, 3954 at lags 2.5 / 5.0 / 7.5 / 10.0, and a tie at lag 0.0 where both sit at 3434. It is worse at every top-N cut of the quarters at the fitted plant (top-1 3934.6 against 3738.7, top-5 3299.7 against 3260.5, top-10 2890.6 against 2871.0, top-50 1882.7 against 1880.0, top-100 1481.7 against 1479.0), and it raises the mean quarter. §3 understated this: the rejection does not rest on one unlucky quarter at one plant, which is exactly the objection a lag-sensitive result invites.

⚠️ WHAT WOULD SETTLE IT IS NOT IN THIS RECORDING. Both objectives above are computed from the same four recorded entities, and they disagree; nothing else in the capture breaks the tie, and this card did not go looking for a third objective after seeing which lag each of the first two preferred. A driven step response — the command timestamped where it is written rather than where Home Assistant recorded it — is what identifies a lag. That is a rig measurement and it is not this card. Until it exists, the export reduction is what this card delivers and the peak figure is a plant-model artefact.

4. What ships

    slew = slew_limit_w(tuning)
    slewed = max(prev_w - slew, min(prev_w + slew, target))
    if slewed != target:
        reason = "slew-limited"
        _lead_cap = SLEW_LEAD_STEPS * slew
        if target > slewed:
            i_w = min(i_w, slewed + _lead_cap)
        elif target < slewed:
            i_w = max(i_w, slewed - _lead_cap)
    target = slewed

Four properties, and each of them is a decision:

  • It is inside the branch, and ⚠️ that placement was a SURVIVOR of all thirty files until a check was written for it. Applying it to every cycle instead — i_w = max(target - cap, min(target + cap, i_w)) after target = slewed, −92 B — is bit-identical on the shipped configuration: 0 of 256 246 ticks differ over the 16-day recording. The two separate only where integrator_max_w is set ABOVE max_w — the configuration control.py says is the only way its clamped reason is reachable. There the integrator is deliberately allowed above the rail, and an unconditional cap would drag it back to the command every cycle and make the option inert. test_telemetry.py part 4 drives that configuration, and the unconditional form is red on it.

    ⚠️ THE COUNT OF SEPARABLE STATES IS DECORATION AND THREE SWEEPS DISAGREE ABOUT IT. This card's grid gave 2050 of 257 400; an independent review measured 1220 of 246 064; a third, smaller grid (four tunings × 7 prev_w × 9 grid_w × 5 actual_w × 8 i_w including None × 4 sat_count) gives 776 of 40 320 — and that third one is spelled out here precisely because the first two are not, which is the whole reason they cannot be reconciled. No count here should be read as exact without the grid beside it. All three agree on the load-bearing part, which is where: every separable state sits in the integrator_max_w > max_w tuning and nowhere else.

    ⚠️ AND AN EARLIER DRAFT OF THIS BULLET GAVE THE WRONG REASON AND THE WRONG NUMBER. It said the two forms differ on 118 ticks because an unconditional cap would pull the integrator toward a command the SATURATION freeze is pinning. That 118 was measured against a different pair — a band applied after quantisation, at the very end of compute() — and the saturation story does not apply to the placement actually being compared, which sits before the freeze. The figures above are the ones that were measured on the pair the sentence names.

    ⚠️ AND THE SATURATION STORY IS NOT MERELY INAPPLICABLE — IT IS BACKWARDS. THE NARROW CAP THAT SHIPPED DOES MOVE A SATURATION-FROZEN INTEGRATOR. It sits before the freeze block and is not gated on frozen. Measured on the same 16 days at the live shape: of 79 160 frozen ticks the cap changed i_w on 1095, and on 679 of those it moved it further out on the side it was already on — putting back magnitude the freeze had just let fall, which is the one direction the freeze's rule ("may fall, never rise") exists to forbid. Worst case 2424 W, at prev_w −3400 W, grid +4697 W, actual −4144 W, sat_count 2 → 3: the freeze leaves the integrator at −176 W, the slew binds, and the cap drags it to −2600 W. It never overrides a same-cycle refusal — the freeze clipped moved on 75 512 ticks and the slew bound on none of them — but the claim that this placement leaves the frozen path alone is false, and §5 is where that shows up in a committed number.

  • It is written one-sided, and that is a CLARITY choice, not a correctness one. ⚠️ An earlier draft of this file and of the comment in control.py said the two-sided band max(slewed - cap, min(slewed + cap, i_w)) was a bug that would drag a TRAILING integrator up toward the command. That was invented, and the sweep said so: the two spellings agree on 205 920 of 205 920 cases across four tunings, every sign of prev_w, grid_w, actual_w and i_w, and the i_w=None path. target is monotone in i_w, so when the slew binds i_w lies on the same side of slewed as target does and the other clamp cannot reach. The one-sided form stays because it NAMES the side the lead is on; the equivalence is recorded so the next reader finds a measurement instead of an argument.

  • It moves i_w and not target. This cycle's command is already decided; what is bounded is what gets CARRIED. That is also why the exhaustive i_w=None equivalence sweep in test_control.py is untouched — it compares (target_w, sat_count, frozen, reason), none of which this moves.

  • The cap is in STEPS, not watts, for SAFETY-39's reason: a watt figure is a constant that outlives the slew_w it was derived against. SLEW_LEAD_STEPS moves with slew_limit_w() and therefore with the rail.

⚠️ ONE STEP, AND NOT ZERO. i_w = slewed is full back-calculation, and control.py's own docstring says what that costs: passing i_w=None "reduces this function to the exact velocity form it replaced". A zero cap makes i_w identically the command on every cycle, which is that collapse written from the other end. Measured, it is better — export 32.02 kWh against 34.07, mean quarter −3.69 W against −2.39 — and it is still refused, because it throws away the reason the accumulator was split out at all. That 2 kWh is the price of keeping the bounded form, and it is stated rather than hidden.

⚠️ TWO LEADS, AND CONFUSING THEM IS HOW A CHECK GETS WRITTEN WRONG. The cap bounds i_w − target, the command this cycle EMITS, at one step — which is what makes the unwind one cycle. Decision.i_w − prev_w, which is what TEL-10's loop_lead publishes, is measured against the command the PREVIOUS cycle left standing, and the emitted command is itself one step further on: the published figure is bounded by two steps. Both bounds are asserted, each against its own number.

The two recorded deadlocks, driven rather than argued

control.py names two encodings that produce a permanently stuck loop, and a gate written on the integrator is exactly the edit that brings them back. Neither is reachable here and both are test cases in test_telemetry.py part 4:

  • the shrink-only encoding — "only corrections that shrink |i_w|" is unsatisfiable for both signs whenever the correction is larger than twice the integrator, and the loop then sits at its last value forever (measured before the fix: 0 W held indefinitely into a 2 kW import). SAFETY-48 is not a shrink rule; it caps the lead beyond one slew step and only while the slew binds.
  • i_w == 0.0 — main.py resets the integrator to exactly 0.0 on every stop and every reseed, and "may not wind further in the direction it is already pushing" has no referent there. SAFETY-48 does not touch that gate; the first push from zero is driven and asserted to move.

And a third, specific to this card: the cap cannot latch. The flag is not carried, and after the cap |i_w − slewed| = cap, so the next cycle's command covers it in one step and the slew stops binding — after which the integrator accumulates freely again. The driven check asserts the command still walks all the way to the rail on a railed export.

5. What this card COSTS, stated rather than smoothed

SAFETY-13's release buys back less. That card's own scenario — a second writer holding the pack at +700 W, the command pinned, then released — settles at −800 W where release/1.0 settled at −1190 W against an integrator of −1194 W; and its sibling run settles at −1200 W where release/1.0 settled at −2390 W. The property AC 4 asserts is unchanged: the released command stops at the frozen integrator and not at the rail.

⚠️ AND THE TWO MOVED FOR TWO DIFFERENT REASONS, WHICH THE ONE-LINE EXPLANATION "how far that integrator had been allowed to wind" COVERS ONLY HALF OF. Gating the cap on not frozen — one line, if frozen: _lead_cap = float("inf"), so the cap becomes a no-op while frozen — puts part 5 back at release/1.0's −1190 W exactly. That run moved because the cap fires on frozen cycles (§4). Part 4(b) does not come back: it is −1200 W gated and −1200 W shipped, against release/1.0's −2390 W, because that one moved for the winding reason. Both literals in test_control.py were re-baselined with the reason beside them, and the second is now asserted against i_w rather than against a number.

The alternative that avoids it was measured and rejected. Gating the cap on not frozen as above gives export 34.71 kWh (against 34.07), worst quarter 3434 W (the same as shipped), wasted 3.49 kWh (against 3.19) — and a worst observed lead of 4961 W, i.e. no bound at all on the quantity this card exists to bound. A bound with an exception large enough to drive a truck through is not a bound. ⚠️ These four figures replace an earlier set (34.9, 3461 W, 3.5 kWh, 4966 W) that does not reproduce on this tree with the gate spelled as above; the gate spelling is written out here so the next reader can re-run it rather than re-derive it.

One quarter-hour in 1534 is 532 W worse. The billed figure is a MAXIMUM, and at the fitted plant lag the maximum improves (3739 → 3434 W); no quarter under SAFETY-48 reaches release/1.0's own worst. The mean moves the right way. The tail is reported because a maximum over 1534 quarters is decided by one of them — and §3a is the reason that improvement is not claimed as a benefit of the card: at every shorter plant lag release/1.0's own maximum is already 3434 W.

6. The independent confirmation, which arrived as a failing check

test_control.py's SAFETY-39 section drives a family of 51 synthetic export steps through a first-order plant this file writes itself — no relation to the replay, no recorded data in it at all. It carried this assertion:

⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION … Quartering the slew from 1000 to 250 does not reduce the over-absorption at all

and it was right about the mechanism and therefore wrong about the conclusion: slew_w bounds the OUTPUT while the integrator accumulates gain · error, so a slower slew left the integrator winding for longer. Worst case over the family:

slew_w release/1.0 this branch
1000 1005 W 729 W
0 (the rail, 556) 1109 W 550 W
400 (the live interim) 1285 W 424 W
250 1330 W 326 W

The ranking inverts. Before: slower is worse, monotonically. After: slower is better, monotonically. Two plant models that share nothing agree on the sign, which is worth more than either alone — and it means SAFETY-39's "gain is the next constant to re-derive" is now only half true: with the lead bounded, the slew owns a share of the over-absorption it did not own before. gain is still not touched here, for SAFETY-39's own reason: two live control-law constants moving in one release makes the next measurement uninterpretable.

⚠️ AND THE RIG SAYS THE SAME THING, WHICH MEANS TWO COMMITTED TRANSCRIPTS CHANGED MEANING UNDER THIS CARD. tools/slew_sim.py --slew runs the same derivation on admin/goodwe's plantsim.Plant, a third model again. Worst-case over-absorption over its 51-step family, slew_w from 1000 down to 250:

slew_w 1000 800 700 600 556 500 400 300 250
release/1.0 958 1107 1133 1058 987 1029 1150 1143 1081
this branch 777 734 663 563 553 507 435 358 321

release/1.0's row is flat and non-monotone and 800 really is better than 556 on that column — SAFETY-39 measured it correctly and its §3 sentence is true of the law it was written against. This branch's row falls monotonically. So SAFETY-39's record is not retracted; it is given a forward reference, and slew_sim.py's own narration now reads SLEW_LEAD_STEPS out of the control law it imported and says which of the two tables it is printing. MUTATION.md habit 8 — a committed transcript is a check, and a transcript whose prose contradicts its own table is a failing one.

7. Verified by breaking it

The law. test_telemetry.py part 4 and test_control.py's SAFETY-39 section. The mutation table is in test_telemetry.py's header.

The instrument (MUTATION.md habit 8 — anything under tools/ that a document quotes is subject to the battery):

# mutation to app/control.py what the instrument did
I2 the whole SAFETY-48 block deleted REFUSED, exit 1, NOTHING HAS BEEN MEASURED — not a zero delta
I3 the anchor line itself edited REFUSED, same message
I3b SLEW_LEAD_STEPS = 1000 — the cap present but inert every row +0.00, which is the honest report that the block is doing nothing
I4 SLEW_LEAD_STEPS = 0 — full back-calculation every row moves further; this is the rejected form's number in §4

I3b is the one that matters: it proves the delta in §3 is caused by the block and not by the two runs differing for some other reason.

The law, driven in the CI image on a purged tree, fingerprinted before, after and after the revert:

# mutation to app/control.py bytes reds
L1 the whole SAFETY-48 block deleted −195 B 5 in test_telemetry.py, 4 in test_control.py
L2 SLEW_LEAD_STEPS = 0 — the velocity form +0 B 4 + 3
L3 SLEW_LEAD_STEPS = 12 — a cap above the rail +1 B 4 + 3
L4 the cap applied unconditionally, outside the slew branch −92 B 1 + 0 — and it was a SURVIVOR of all thirty files before that check existed
L5a the min/max FUNCTION names exchanged +0 B 5 + 5, one of them test_control.py's own "frozen integrator never deadlocks" over 13 041 states
L5b the two BRANCH BODIES exchanged — the other reading of "the cap on the wrong side" +0 B 5 + 4, and it does not reach the deadlock sweep
L6 the lead measured against prev_w rather than the command emitted +0 B 6 + 3

⚠️ L5 AND L6 ARE BOTH ZERO BYTES — min/max and slewed/prev_w are each the same length — so no fingerprint and no byte delta can see either, and one of L5a's reds is test_control.py's own "frozen integrator never deadlocks" sweep over 13 041 states, written by SAFETY-13 for a different reason.

⚠️ THREE OF THESE COUNTS WERE WRONG AND ARE RE-MEASURED ABOVE, WHICH IS MUTATION.md's REPORTING RULE 3 APPLIED TO THIS CARD'S OWN TABLE. The earlier row gave L5 as 2 + 5 and L6 as 4 + 3; driven again in the CI image on a cp -a of this tree, purged, :ro, one file at a time, they are 5 + 5 and 6 + 3. ⚠️ AND "L5" NAMED TWO DIFFERENT MUTATIONS. "min/max swapped" can mean the function names exchanged or the branch bodies exchanged; they are both red, they are both the cap on the wrong side, and they give different counts (5 + 5 against 5 + 4) and reach different checks. A row that writes one number for a mutation a reader cannot re-derive from its own description is a row that cannot be re-run, so both spellings are listed. The directions in the original table were all correct; the counts were decoration, and decoration in a mutation table reads as evidence.

7b. The transcript, quoted rather than summarised

python3 tools/replay_recorded_loop.py --sensitivity, on this branch, 58 s:

THE RECORDING: 256252 meter instants over 16.00 days, loop-2026-09-02_2026-09-17
    tick   p5 4.77 s   median 5.01 s   p95 7.86 s   (control.LOOP_TICK_S says 2.0)
    the recorded command: extremes -5000 / +5000 W  ->  max_w 5000;  most common step above 100 W is 400 W, 9924 times  ->  slew_w 400
    the fitted inverter: lag 7.5 s, tau 2.0 s, batt = 0.9731 x cmd -22.4 W, residual 375 W RMS above the SoC floor

FIDELITY - the BEFORE law replayed against the recording it came from
    setpoint  r=0.887  mean     +601 W simulated vs     +739 W recorded
    grid      r=0.682  mean     +195 W simulated vs     +239 W recorded
    export       39.61 kWh simulated vs    29.43 kWh recorded  ->  the model is +35 % PESSIMISTIC about the loop's tracking

THE A/B, same ticks, same disturbance, same plant, same seed
                                 release/1.0     SAFETY-48       delta
    export kWh                         39.61         34.07       -5.54   the cost
    import kWh                        116.07        111.20       -4.86
    battery sign reversals           2992.00       2770.00     -222.00
    wasted throughput kWh               4.67          3.19       -1.48
    worst 15-min offtake W           3738.67       3433.94     -304.74   ⚠️ THE BILLED ONE
    recovery events                   549.00        612.00      +63.00
    recovery median s                 232.06        199.04      -33.01
    recovery p99 s                    849.00        849.94       +0.94
    recovery max s                    901.06        898.40       -2.66
    unrecovered (>900 s)               53.00         31.00      -22.00
    |i_w - prev_w| p99 W             3361.12        800.00    -2561.12
    |i_w - prev_w| max W             9200.00        800.00    -8400.00
    slew-limited ticks              16392.00      11654.00    -4738.00
    mean quarter delta W                                         -2.39   over 1534 quarters
    worst quarter delta W                                      +532.27

SENSITIVITY - the ranking, not the watts
      variant                               export kWh           worst quarter W   mean quarter dW
      plant lag 5.0 s                32.1 ->     27.2         3434 ->     3434             -1.02
      plant lag 10.0 s               75.4 ->     68.3         3954 ->     3434             -4.65
      plant tau 8 s                  56.5 ->     49.8         3995 ->     3605             -3.30
      gain 1.0135, offset -26        46.8 ->     39.3         3713 ->     3434             -3.27
      slew_w 555.6 (the rail)        36.5 ->     34.4         3434 ->     3434             -0.17
      gain 0.3                       28.1 ->     27.0         3434 ->     3434             +0.16

    (58 s)

⚠️ --sensitivity IS THE RANKING AND NOT THE WATTS. The export figure moves by a factor of two across these plants; what does not move is the direction — on export. ⚠️ THE worst quarter W COLUMN IS NOT THE SAME KIND OF EVIDENCE AND THIS PARAGRAPH USED TO TREAT IT AS IF IT WERE. Three of its six rows read 3434 -> 3434, and that is not "the direction holds": it is release/1.0 already sitting at 3434 W on those plants, with nothing for the card to improve. §3a sweeps the lag properly and finds the same thing at every lag ≤ 5.0 s. The billed-quarter improvement in the A/B table above exists at one plant, the one fit_plant() selects by an objective that separates it from its neighbour by 0.3 %.

Two rows deserve naming rather than rounding: at slew_w: 555.6 (the rail) and at gain: 0.3 the mean quarter-hour delta is −0.17 W and +0.16 W — ties, not improvements. Both are regimes where |error| > slew_w / gain is rarely satisfied (1333 W at gain: 0.3, 926 W at the rail against a measured median onset error of 1114 W), so there is little for the card to bite on. That is the mechanism agreeing with itself, and it is also the honest answer to "would this still be worth shipping if the tuning moved": at gain: 0.3 it would not be.

8. What this card does NOT establish

  • Nothing here drives an inverter. The replay is a model with a 375 W residual and no arbiter in it. The closed-loop answer comes from the rig or from a live run — SAFETY-47 records why a shadow run cannot give it.
  • The live tick is 5.0 s, not LOOP_TICK_S's 2.0 s, measured off 9924 recorded 400 W steps and off the meter's own cadence. It is wrong in the SAFE direction (a slower meter makes validate.py rule 10 conservative) and is NOT changed here — re-deriving SLEW_RAIL_FRAC is a refusal boundary on a live install and belongs to its own card. It is recorded in the tool's header and printed on every run.
  • The brief's plant figure was close but not exact. It quoted delivered = 1.0135 × command − 26 W, r = 0.992 at a 4–6 s publish lag. On this 16-day window, above the SoC floor, the same relation measures 1.00 × command − 28 W, r = 0.977 at a best-fit pure delay of 7.5 s. The conclusion — the inverter is a pure follower and the plant is not the problem — survives; the coefficients are the ones in §2.
  • The brief's cost figure is larger than this measurement. It quoted ~14 kWh of avoidable export and 65 % of site export inside 7.9 % of the time. Measured here: 5.5 kWh avoidable of 39.6 kWh modelled (29.4 kWh recorded), with 42 % of export inside the 6.3 % of ticks that are slew-limited and 69 % within ±60 s of one, over 15.3 % of the time. The concentration claim holds; the avoidable-kWh figure does not, and the difference is not reconcilable from here because the reconstruction it came from was discarded.