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
33 KiB
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
- It is a missing case, not a wrong constant, and the arithmetic says so.
The integrator moves
gain * errora cycle and the command moves at mostslew_w, so the integrator outruns the command whenever|error| > slew_w / gain— 667 W at the live shape. SAFETY-39's retune madeslew_wsmaller, which makes that threshold lower. §1. - ⚠️ 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.
- 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.
- 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))aftertarget = 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 whereintegrator_max_wis set ABOVEmax_w— the configurationcontrol.pysays is the only way itsclampedreason 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.pypart 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× 9grid_w× 5actual_w× 8i_wincludingNone× 4sat_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 theintegrator_max_w > max_wtuning 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 changedi_won 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, atprev_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 clippedmovedon 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.pysaid the two-sided bandmax(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 ofprev_w,grid_w,actual_wandi_w, and thei_w=Nonepath.targetis monotone ini_w, so when the slew bindsi_wlies on the same side ofslewedastargetdoes 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_wand nottarget. This cycle's command is already decided; what is bounded is what gets CARRIED. That is also why the exhaustivei_w=Noneequivalence sweep intest_control.pyis 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_wit was derived against.SLEW_LEAD_STEPSmoves withslew_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.pyresets 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 makesvalidate.pyrule 10 conservative) and is NOT changed here — re-derivingSLEW_RAIL_FRACis 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.992at a 4–6 s publish lag. On this 16-day window, above the SoC floor, the same relation measures1.00 × command − 28 W, r = 0.977at 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.