checks / unit (pull_request) Successful in 7m55s
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
552 lines
33 KiB
Markdown
552 lines
33 KiB
Markdown
# 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:
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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.
|