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

`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, `abs(prev_w - actual_w) > saturation_w`, and slew-limiting
never enters it. 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, against a measured median
grid error of 1114 W at the onset of a recorded episode. SAFETY-39's retune
made it MORE frequent, not less - a smaller step is a lower threshold.

⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, WHICH IS WHY IT IS
NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag was
built from release/1.0's own source and measured over 16.00 days / 256 252
meter instants of the live install's recorder: export 39.61 -> 34.43 kWh, and
the worst quarter-hour average OFFTAKE 3739 -> 3935 W with the mean quarter
+2.73 W, in five of six plant and tuning variants. A frozen integrator stops at
`prev + gain*error`, which undershoots by (1 - gain); for an export that is
free and for an import it is offtake, for as long as the ramp lasts. The card
said "do not invent a second rule"; the measurement says to, and both sentences
are recorded.

WHAT SHIPS BOUNDS THE LEAD AT ONE SLEW STEP, inside the branch where the slew
actually bound. Same 16 days, same ticks, same disturbance, same plant, same
seed: export 39.61 -> 34.07 kWh, wasted throughput 4.67 -> 3.19 kWh, battery
sign reversals 2992 -> 2770, median recovery 232 -> 199 s, p99 849 -> 850 s,
max 901 -> 898 s, unrecovered 53 -> 31, worst |i_w - prev_w| 9200 -> 800 W -
and the worst 15-minute average offtake 3739 -> 3434 W, i.e. the billed
quantity IMPROVES. `i_w = slewed` (a cap of zero) is better again at 32.02 kWh
and is refused: it is the velocity form this module's docstring says `i_w=None`
collapses to.

TWO COMMITTED NUMBERS MOVED, DELIBERATELY, WITH THE REASON BESIDE THEM.
SAFETY-13's released command settles at -800 W and -1200 W where release/1.0
settled at -1190 W and -2390 W, because there is less wound integrator to
settle onto; AC 4's property - it stops at the frozen integrator, not the rail
- is what is asserted and the second is now graded against `i_w` rather than a
literal. And SAFETY-39's "the slew does not own the over-absorption" is now
FALSE - INVERTED, on the independent first-order plant test_control.py writes
itself: 1005/1109/1285/1330 W rising with a slower slew becomes 729/550/424/326
falling. Two plant models that share nothing agree on the sign.

Evidence: `tools/replay_recorded_loop.py` plus the read-only capture it made
(scenarios/loop-2026-09-02_2026-09-17). The "before" law is the shipped file
with the block removed by a named substitution - never a transcription - and a
missing anchor is a REFUSAL carrying "NOTHING HAS BEEN MEASURED". The
instrument prints its own fidelity first: it reproduces 39.6 kWh of export
where the install exported 29.4, i.e. it is ~35 % pessimistic about the loop's
tracking, so only the delta is claimed.

30/30 suites green, including test_control.py's exhaustive i_w=None
equivalence sweep against the transcribed release/1.0 law, untouched: the cap
moves `i_w` and not `target`, and that sweep compares (target_w, sat_count,
frozen, reason).

Six law mutations, one instrument battery. L4 - the cap applied
unconditionally, -92 B - was a SURVIVOR of all thirty files: it is
bit-identical on the shipped configuration (0 of 256 246 ticks) and separable
on 2050 of 257 400 swept states, all at `integrator_max_w` above `max_w`, which
part 4 now drives. L5 and L6 are ZERO BYTES.

AND TWO COMMENTS IN THIS CARD WERE INVENTED AND ARE CORRECTED IN PLACE. The
one-sided spelling was justified by a two-sided form that would "drag a
trailing integrator" - it does not, and the two agree on 205 920 of 205 920
swept states; and a "118 of 256 252 ticks" figure had been measured against a
different pair. MUTATION.md gains habits 21 and 22 for both shapes.

NOT DEPLOYED, NOT VERSIONED. The live install still runs release/1.0's law.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-19 02:36:28 +02:00
co-authored by Claude Opus 5
parent b6c6da31d8
commit 03ef61fa3b
72 changed files with 2297 additions and 49 deletions
@@ -0,0 +1,397 @@
# 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. A frozen integrator
stops at `prev + gain·error`, which undershoots what the site needs by
`1 − gain`, so a large import is answered too weakly for as long as the ramp
lasts. §3.
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.** Export 39.6 → 34.1
kWh, wasted throughput 4.67 → 3.19 kWh, sign reversals 2992 → 2770, worst
quarter-hour offtake 3739 → **3434 W** (it improves), median recovery 232 →
199 s. §4.
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 mechanism is not subtle once it is stated: 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`. For an EXPORT that under-correction is free;
for an IMPORT it is offtake, for as long as the ramp lasts.
⚠️ **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.
## 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 are separable on
**2050 of 257 400** swept states, every one of them at `integrator_max_w`
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.
⚠️ **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.
* **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. What changed is how far that
integrator had been allowed to wind. 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` leaves SAFETY-13 bit-identical and gives export 34.9 kWh (against
34.07), worst quarter 3461 W (against 3434), wasted 3.5 kWh (against 3.19) — and
a worst observed lead of **4966 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.
**One quarter-hour in 1534 is 532 W worse.** The billed figure is a MAXIMUM, and
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.
## 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.
## 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 — **and it was a SURVIVOR of all thirty files before that check existed** |
| L5 | `min`/`max` swapped — the cap on the wrong side | +0 B | 2 + 5, one of them `test_control.py`'s own "frozen integrator never deadlocks" over 13 041 states |
| L6 | the lead measured against `prev_w` rather than the command emitted | +0 B | 4 + 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 L5'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.
## 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.
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.
+19
View File
@@ -371,6 +371,25 @@ the number of cycles needed to unwind it is that lead divided by `slew_w`.
Both numbers are `state_class: measurement`, so Home Assistant's recorder keeps
long-term statistics for them.
⚠️ **`Loop integrator lead` is BOUNDED, and the bound is two slew steps.** While
the slew limit is what is holding the command back, the integrator may not stand
more than **one** `slew_w` ahead of the command it emits — so the command always
covers the gap in one cycle, and a reversal costs one cycle of unwind rather
than `integrator_max_w / slew_w` (12.5 at `max_w: 5000`, `slew_w: 400`).
Measured against the *previous* cycle's command, which is what this row
publishes, that is two steps: 800 W at the live shape. **A published lead
persistently above two slew steps means something is wrong** — either the
saturation freeze is holding the command (`Loop reason` says
`saturated-freeze`), or the bound is not doing its job.
Before this bound existed, the same 6 kW export drove the lead to 4600 W and the
integrator to its 5000 W rail, and the command then needed twelve cycles — about
a minute at this install's meter rate — to walk back down once the disturbance
cleared. Measured over 16 days of the reference install's own recorded
disturbances, bounding it cut avoidable export by 5.5 kWh, cut wasted battery
throughput from 4.67 to 3.19 kWh, and **lowered** the worst quarter-hour average
offtake — the billed one — from 3739 W to 3434 W.
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
+41
View File
@@ -811,6 +811,47 @@ Cheap habits fall out of it, and all of them are now used in this repository:
retrofitted: guarding another card's section is a change to what its
checks report, and it belongs to whoever next touches it.
21. ⚠️ **A CHECK CAN BE GREEN UNDER ITS OWN CARD'S NAMED MUTATION, AND TWICE
IN ONE FILE THE REASON WAS THAT THE WRONG QUANTITY EVENTUALLY REACHES THE
RIGHT VALUE.** TEL-10's acceptance was *"a run that winds the integrator
into the bound must show the published number reaching the bound"*, and its
named mutation was *"publish `prev_w` instead of `i_w`"*. The check was
**GREEN** on the mutant: the command reaches the same bound too, twelve
cycles later, and a check that only reads the VALUE cannot tell the two
apart. The same file's *"a stopped controller publishes `not-running`"* was
green under deleting that branch's assignments outright (−45 B and −67 B),
because the fixture STARTED stopped and `__init__` writes the same values.
Both are habit 4 - the data could not reach the failure - and both were
found by the card's own battery. The fixes are the shape to copy:
* where two quantities CONVERGE, grade the **order or the interval**, not
the value: "the integrator reaches the bound BEFORE the command does",
and a PLATEAU (the lead sits at exactly −800 W for eight consecutive
cycles) rather than a maximum, because a maximum is one tick and a
plateau is the shape;
* where a branch WRITES a value the constructor also writes, the fixture
has to pass THROUGH the other state first. Stop the controller part-way
through a run that has already wound the integrator to −5000 W, so a row
left standing is a WRONG row and not merely an unset one.
22. ⚠️ **AN EXPLANATION IN A COMMENT IS A CLAIM, AND SAFETY-48 SHIPPED TWO THAT
WERE INVENTED.** A gate was written one-sided (`if target > slewed: min(...)
elif target < slewed: max(...)`) under a comment saying the two-sided band
`max(lo, min(hi, i_w))` was a BUG that would drag a trailing integrator
toward the command, with a worked example. It is not a bug and the example
was wrong: `target` is monotone in `i_w`, so the other clamp cannot reach,
and the two spellings agree on **205 920 of 205 920** swept states. A second
bullet said two placements of the same cap *"differ on 118 of 256 252
ticks"*; that 118 had been measured against a **different pair** and the
placement actually being compared is bit-identical on the shipped
configuration - 0 of 256 246 ticks. Both sentences were plausible, both were
load-bearing for a design choice, and neither had been run.
**The rule: a comment that says "X would be wrong" is subject to the same
battery as a check - BUILD X AND MEASURE IT.** Where the answer is
"equivalent", say so with the sweep size and keep the spelling for what it
NAMES; where the answer is a number, quote the run it came from. An
invented justification for a correct decision is still a defect, and it is
the kind a reviewer cannot see because the code is right.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+106
View File
@@ -191,6 +191,68 @@ SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
# edge rather than in prose, because it is a property of two files agreeing.
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
# ---------------------------------------------------------------------------
# SAFETY-48. HOW FAR THE INTEGRATOR MAY STAND AHEAD OF THE COMMAND.
#
# ⚠️ THE MISSING CASE, AND IT IS A CASE AND NOT A CONSTANT. The freeze below
# stops the integrator accumulating, and the comment beside it says what the
# requirement is: "the INTEGRATOR stop accumulating, not only the command". But
# `frozen` is computed from ONE signal - `abs(prev_w - actual_w) >
# saturation_w`, the inverter not tracking - and SLEW-LIMITING NEVER ENTERS IT.
# When the command hits the step ceiling, the integrator goes on winding at
# `gain * error` until it reaches `integrator_max_w`.
#
# The arithmetic, and it is the whole card:
#
# the integrator moves gain * error per cycle
# the command moves at most slew per cycle
# so the integrator outruns the command whenever
#
# |error| > slew / gain
#
# At the live install's shape (gain 0.6, slew_w 400) that is 667 W. The median
# grid error at the onset of a recorded episode is 1114 W. This is why
# SAFETY-39's `slew_w` retune did not fix it: a smaller step makes the
# threshold LOWER, so the case fires more often, not less.
#
# ⚠️ WHAT IS BOUNDED IS THE LEAD, NOT THE INTEGRATOR. Two forms were measured
# over 15.7 days of the live install's own recorded disturbances
# (`tools/replay_recorded_loop.py`, and DECISIONS/SAFETY-48 has the table):
#
# (a) reuse the asymmetric freeze on a carried slew flag - "may not wind
# further in the direction it is already pushing", exactly as saturation
# does. It cuts export 13 % AND RAISES THE BILLED QUANTITY: the worst
# quarter-hour average OFFTAKE goes 3839 -> 3935 W and the mean quarter
# moves +2.7 W, in five of six plant and tuning variants. A frozen
# integrator stops at `prev + gain * error(first cycle)`, which UNDER-
# shoots what the site needs by (1 - gain), so a large import is answered
# too weakly for as long as the ramp lasts. That is the wrong trade on a
# capacity tariff and the reason this is not that fix.
#
# (b) cap how far the integrator may LEAD the command, one slew step. Export
# 39.8 -> 34.1 kWh, wasted throughput 4.7 -> 3.2 kWh, sign reversals
# 3050 -> 2806, worst quarter-hour offtake 3839 -> 3434 W (it IMPROVES),
# median recovery 237 -> 214 s. It is what ships.
#
# ⚠️ AND THE CAP IS NOT ZERO. `i_w = slewed` is full back-calculation, and this
# module'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 same collapse written
# from the other end - it measures 32.1 kWh, better than (b) by 2 kWh, and it
# throws away the reason the accumulator was split out at all. One step is the
# smallest cap that is not that.
#
# WHAT THE NUMBER MEANS: the command can cover a one-step lead in ONE cycle, so
# the worst-case unwind after a reversal is one cycle instead of
# `integrator_max_w / slew_w` - 12.5 cycles at the live shape, ~60 s at the
# meter's measured 4.84 s tick.
#
# ⚠️ IT IS IN STEPS RATHER THAN IN WATTS FOR SAFETY-39's REASON, WHICH IS THE
# REASON THIS FILE KEEPS RE-LEARNING. A watt figure is a constant that outlives
# the `slew_w` it was derived against; expressed in steps it moves with
# `slew_limit_w()` and therefore with the rail.
SLEW_LEAD_STEPS = 1
@dataclass(frozen=True)
class Tuning:
@@ -507,6 +569,50 @@ def compute(
slewed = max(prev_w - slew, min(prev_w + slew, target))
if slewed != target:
reason = "slew-limited"
# --- SAFETY-48: the step ceiling reaches the integrator too ---------
# ⚠️ INSIDE THIS BRANCH AND NOWHERE ELSE. The bound is on the lead the
# SLEW created, so it is applied only where the slew is what stopped
# the command. Applied unconditionally it would also pull the
# integrator toward a command the SATURATION freeze is pinning - and
# SAFETY-13's whole design is that the frozen integrator keeps standing
# where it is, so that releasing the freeze buys the standing error
# back. Measured over the recording, the two forms differ on 118 of
# 256 250 ticks and agree on every reported figure to one decimal; the
# narrow one is written because it is the one whose scope matches its
# sentence.
#
# ⚠️ WRITTEN ONE-SIDED, AND IT IS **EQUIVALENT** TO THE TWO-SIDED BAND
# `max(slewed - cap, min(slewed + cap, i_w))` - MEASURED, NOT REASONED.
# An earlier draft of this comment claimed the two-sided spelling was a
# bug that would drag a TRAILING integrator up toward the command. That
# is false and the sweep said so: `target` is monotone in `i_w`
# (`target = clamp(clamp(i_w, +-limit), +-max_w)`) and `slewed` is
# `target` pulled toward `prev_w`, so whenever the slew binds, `i_w`
# lies on the same side of `slewed` as `target` does and the other
# clamp cannot reach. 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.
#
# It stays one-sided because it SAYS which side the lead is on - the
# side the command wanted to move and could not - and because a reader
# reaching for the band spelling should find the sweep rather than an
# argument. ⚠️ The "descending" case is NOT an exception to the rule: a
# command falling from +2400 W toward an integrator at +100 W has an
# integrator 2300 W ahead of it IN THE DIRECTION OF TRAVEL, and it is
# pulled back to one step beyond the emitted command exactly as a
# rising one is.
#
# ⚠️ `i_w` IS CHANGED AND `target` IS NOT. 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 by this card: it compares `(target_w, sat_count, frozen,
# reason)`, none of which this moves, and the seeded path re-seeds from
# `prev_w` next cycle anyway.
_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
if frozen:
@@ -0,0 +1,799 @@
{
"generated": "2026-09-18T23:52:32.622227+02:00",
"generator": "tools/replay_recorded_loop.py --export",
"source": "http://192.168.2.158:8123/api/history/period",
"timezone": "Europe/Brussels",
"entities": {
"grid": "sensor.p1_meter_active_power",
"batt": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"setpoint": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"soc": "sensor.goodwe_rs485_controller_goodwe_battery_soc"
},
"format": "{\"t\": <ISO-8601 with offset>, \"v\": <state string>} per line, gzip, mtime 0",
"days": [
{
"date": "2026-09-02",
"window": [
"2026-09-02T00:00:00+02:00",
"2026-09-03T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16515,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-grid.jsonl.gz",
"bytes_raw": 922104,
"sha256_raw": "8e61b964fd9ebe047dfcc70f32f08ce2bebe7ac3ffe6f97000e3526a5637e9c4"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3299,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-batt.jsonl.gz",
"bytes_raw": 187888,
"sha256_raw": "0e2f392158a7fd2dc77287d3f94de1edab81c6a9a7e16b0f59f525145fe6a61b"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4683,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-setpoint.jsonl.gz",
"bytes_raw": 267407,
"sha256_raw": "2f486e27bffdce82f16d20104fcdbbe7057ebd4c025c9ac00eb59531a44d1ea1"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 130,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-02-soc.jsonl.gz",
"bytes_raw": 7141,
"sha256_raw": "090d14866552b4dc27cf0c0505cfb5c4d7e7223b25a5af504598618aeba317ed"
}
}
},
{
"date": "2026-09-03",
"window": [
"2026-09-03T00:00:00+02:00",
"2026-09-04T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16419,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-grid.jsonl.gz",
"bytes_raw": 919508,
"sha256_raw": "fd4230b864a2f789fcac6da7bbe87f0445fedbc81818d3c4a1c1a41655ed5b4f"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3434,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-batt.jsonl.gz",
"bytes_raw": 194783,
"sha256_raw": "3a12c37dfe33a571b68f27a68d88b38170f4acd39f4c9574f58de7a9c358fe8e"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5145,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-setpoint.jsonl.gz",
"bytes_raw": 292456,
"sha256_raw": "c8c70155151c44ef92cd7048dd5642838e2fcd5dfa4989db30d39f91e9031aa2"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 69,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-03-soc.jsonl.gz",
"bytes_raw": 3786,
"sha256_raw": "a34ccf6a175d0b60b0852a305be69457c0e0a42f3f9a1c777ab100039cc57c24"
}
}
},
{
"date": "2026-09-04",
"window": [
"2026-09-04T00:00:00+02:00",
"2026-09-05T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16098,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-04-grid.jsonl.gz",
"bytes_raw": 897558,
"sha256_raw": "4c1528faa35778a3fe6a7b613482af4ef854e6b1b92ba8a6c72aa231158cf93f"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3009,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-04-batt.jsonl.gz",
"bytes_raw": 170410,
"sha256_raw": "2ee54e9c6670ebc87bb120bb98a572dbe6b129c3a2e3aa7802c5993ecab03500"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4300,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-04-setpoint.jsonl.gz",
"bytes_raw": 244058,
"sha256_raw": "7130b266efe61447276183a8da1b79e14e1e2a56b92b9884c5cb3c1d277cf002"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 47,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-04-soc.jsonl.gz",
"bytes_raw": 2583,
"sha256_raw": "833ed5f48b3fac83beb65cb1c1b00ed66597505bcab37813997c3f3f908b72bd"
}
}
},
{
"date": "2026-09-05",
"window": [
"2026-09-05T00:00:00+02:00",
"2026-09-06T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16448,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-05-grid.jsonl.gz",
"bytes_raw": 920613,
"sha256_raw": "03cb4e2b3e075b060a47bf5d3cb83d5c55d0055512dcec800a4aaf5bbae91b3e"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4409,
"non_numeric_states": 6,
"attempts": 1,
"file": "2026-09-05-batt.jsonl.gz",
"bytes_raw": 251332,
"sha256_raw": "a11c722ff106df1675e57e9d98af37ec22bf61d509fa847c30c31a50165c07a1"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 6339,
"non_numeric_states": 6,
"attempts": 1,
"file": "2026-09-05-setpoint.jsonl.gz",
"bytes_raw": 361644,
"sha256_raw": "7929161bfaf654dab097d02219b3a1c83d1530e78a0e38776115792afd45fb8d"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 132,
"non_numeric_states": 6,
"attempts": 1,
"file": "2026-09-05-soc.jsonl.gz",
"bytes_raw": 7289,
"sha256_raw": "139d883516de462655fe34902c30cc09a329ace6fbb248b06f8ffd8b1d6edaa2"
}
}
},
{
"date": "2026-09-06",
"window": [
"2026-09-06T00:00:00+02:00",
"2026-09-07T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16060,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-grid.jsonl.gz",
"bytes_raw": 899105,
"sha256_raw": "7d3e92c36834aeb1992d611cea6d256967585b45c3c9251b00dac676befebbea"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3569,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-batt.jsonl.gz",
"bytes_raw": 202858,
"sha256_raw": "5030a36a36b9ae6867c582c70fb7cf57b6fe762aa1e52993ab48d4f5c4269ef4"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5190,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-setpoint.jsonl.gz",
"bytes_raw": 295576,
"sha256_raw": "d361daab3fa61bae75779d574e3619d3cc0908501187f054b16643caa2e0f231"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 83,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-06-soc.jsonl.gz",
"bytes_raw": 4556,
"sha256_raw": "6ee9813016c7a1bdb62f6f4e892e8c3351491e1c5324c67862c068cebee8c2d1"
}
}
},
{
"date": "2026-09-07",
"window": [
"2026-09-07T00:00:00+02:00",
"2026-09-08T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15512,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-grid.jsonl.gz",
"bytes_raw": 868060,
"sha256_raw": "69ff04ecc0df7faa11824b3a4f2e4bd5a84167aa260386380d842da84da4f152"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3646,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-batt.jsonl.gz",
"bytes_raw": 207432,
"sha256_raw": "37a68612eadcecf80d6e796f653ae8e4ec9ff98a40a8d560ca18df995b47ad14"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 3521,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-setpoint.jsonl.gz",
"bytes_raw": 201333,
"sha256_raw": "1c0a93b59c46f7338e81a1a8f0609db7e140793bc28c9a66d2087eaf2360eb2b"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 138,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-07-soc.jsonl.gz",
"bytes_raw": 7584,
"sha256_raw": "bea3599b83e280a11a3a6b83b2626db15acaf04c2d7db16734a1acc76f691bf8"
}
}
},
{
"date": "2026-09-08",
"window": [
"2026-09-08T00:00:00+02:00",
"2026-09-09T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15691,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-grid.jsonl.gz",
"bytes_raw": 872562,
"sha256_raw": "632c58235dde62178358a72c445945829dfbb580cf9dbd1eef32c545ea29154b"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4451,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-batt.jsonl.gz",
"bytes_raw": 251748,
"sha256_raw": "0d94ce9c4e41e33094fb7a3ffafbec15c9d9ad1b741aaa0b77e39a9ca8b64572"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5145,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-setpoint.jsonl.gz",
"bytes_raw": 292178,
"sha256_raw": "d29a15925e1a87c57a120828dd6f138b0ee3738f0418f789e6b14451785dcab4"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 118,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-08-soc.jsonl.gz",
"bytes_raw": 6481,
"sha256_raw": "f07d9cd878763dcda6e8bf58653d260a5d897f4bc65efd446707e0af5a84c813"
}
}
},
{
"date": "2026-09-09",
"window": [
"2026-09-09T00:00:00+02:00",
"2026-09-10T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16017,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-09-grid.jsonl.gz",
"bytes_raw": 894526,
"sha256_raw": "50924035a087895b89b72f6acf5db24a3b9ed704362968854e1328493e0c7758"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4124,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-09-batt.jsonl.gz",
"bytes_raw": 235314,
"sha256_raw": "93e26b5f40b20d1478c457b7cae06f899c2b52f1ce2abba90fe08ca14ad4607e"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5855,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-09-setpoint.jsonl.gz",
"bytes_raw": 334751,
"sha256_raw": "d5286bc477b825343804e4fd8eb85e9a35c3a8e98e83abba9df5d2879637bab9"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 123,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-09-soc.jsonl.gz",
"bytes_raw": 6757,
"sha256_raw": "c4b6b1a3fc3de4e928db2f6ac7520a7556893e1b588d2f57711a4c7ded6646d4"
}
}
},
{
"date": "2026-09-10",
"window": [
"2026-09-10T00:00:00+02:00",
"2026-09-11T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16172,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-grid.jsonl.gz",
"bytes_raw": 902807,
"sha256_raw": "7a388548ce9b10374354afe17ba10567f7a592c38b7614b60e570d12a8c4cc87"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4322,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-batt.jsonl.gz",
"bytes_raw": 246291,
"sha256_raw": "e01417fb54e163f188feed3aab464cd7e125d1c8c6e2b5ed51066d86f84cc221"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5450,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-setpoint.jsonl.gz",
"bytes_raw": 310937,
"sha256_raw": "5fd7917fa89459a7f89eb838cc07d09f2dda85964f45df27a16d49a5608af797"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 142,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-10-soc.jsonl.gz",
"bytes_raw": 7802,
"sha256_raw": "ff94001915949d165377ad6b2ac8dd0f00c84a0cd1c8a83fbddf474543dc1871"
}
}
},
{
"date": "2026-09-11",
"window": [
"2026-09-11T00:00:00+02:00",
"2026-09-12T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15975,
"non_numeric_states": 1,
"attempts": 1,
"file": "2026-09-11-grid.jsonl.gz",
"bytes_raw": 889154,
"sha256_raw": "335847b7f757a1fa3682be000ef3e77dcf637ccb52b9ad3d739539d1a7fd1384"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4399,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-11-batt.jsonl.gz",
"bytes_raw": 250012,
"sha256_raw": "bd91e7a0e33cbec1737fb95019746c44bdf3ff71e71933ff99b45383b3bcef29"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5519,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-11-setpoint.jsonl.gz",
"bytes_raw": 315079,
"sha256_raw": "8f538f230ff2076a5a098d27a6d26390c9995e88a14b9b30e7b141bd216b211a"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 124,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-11-soc.jsonl.gz",
"bytes_raw": 6812,
"sha256_raw": "d090fe60687ce35498afa923089881d1eaec3b50be2604ffe4ac4261c6d7f8b5"
}
}
},
{
"date": "2026-09-12",
"window": [
"2026-09-12T00:00:00+02:00",
"2026-09-13T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16001,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-grid.jsonl.gz",
"bytes_raw": 893240,
"sha256_raw": "3ff17d8fa70133338cd4c54067b7c37d5f33fd971d8037e99b8f829703d42a2d"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3744,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-batt.jsonl.gz",
"bytes_raw": 213926,
"sha256_raw": "228b3498d915d6b96781dca039899816b4a9c45874af019095cede1128c2d2aa"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5012,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-setpoint.jsonl.gz",
"bytes_raw": 287190,
"sha256_raw": "bfb3389a021c2b01a005901c961c2c30bb6ca4a42e5e80c00855ea1144f3464a"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 126,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-12-soc.jsonl.gz",
"bytes_raw": 6922,
"sha256_raw": "ca17b220922550d9d00330f62742edf9b9cdf990cd3647a14a580f3427023092"
}
}
},
{
"date": "2026-09-13",
"window": [
"2026-09-13T00:00:00+02:00",
"2026-09-14T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15912,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-grid.jsonl.gz",
"bytes_raw": 888912,
"sha256_raw": "79a2f5462c396cc26c90c72e2b38390f1665bb41b77fc9120019595228133ca3"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3432,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-batt.jsonl.gz",
"bytes_raw": 193080,
"sha256_raw": "9410b9b80632c2ad68cd0632a5dd6513077eaecfc7a776986df867f847b19b39"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 3566,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-setpoint.jsonl.gz",
"bytes_raw": 201353,
"sha256_raw": "8173b590ed7a33d441cc0f2fb447ad88ce1c12aef6019155124132f44b11981c"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 80,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-13-soc.jsonl.gz",
"bytes_raw": 4391,
"sha256_raw": "15a8328cc07964216be4f3a6001fb640937e33f81b13c64e8da0872ab0da8a46"
}
}
},
{
"date": "2026-09-14",
"window": [
"2026-09-14T00:00:00+02:00",
"2026-09-15T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 16086,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-grid.jsonl.gz",
"bytes_raw": 904274,
"sha256_raw": "26c255b793432474a45dc6f0fff156f358f8468f2381e8161f4d0812d732e754"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 2679,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-batt.jsonl.gz",
"bytes_raw": 152706,
"sha256_raw": "d2bec4260c3649db865744a93f20c38c8f76b4f09f2e0b2308e61280b415321d"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4007,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-setpoint.jsonl.gz",
"bytes_raw": 229164,
"sha256_raw": "4b3feb9cf7349bf8d6b3c4b6daa7d8b598f5589e52dfe48c98791d89e5678d79"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 75,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-14-soc.jsonl.gz",
"bytes_raw": 4116,
"sha256_raw": "31b3d54b0b4ea4e2e5ee5fc0131c05bea5f920996699888ac764b66d19763d89"
}
}
},
{
"date": "2026-09-15",
"window": [
"2026-09-15T00:00:00+02:00",
"2026-09-16T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15947,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-grid.jsonl.gz",
"bytes_raw": 890223,
"sha256_raw": "6a6d39306d47b08dc190d576b3b7a2e486a755ad846fa721262a53002305bb2c"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3651,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-batt.jsonl.gz",
"bytes_raw": 208309,
"sha256_raw": "4b60ab3ab24b9fc6dbde7262d36c74b0ef164429e382448c48ceea709921ddf4"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4641,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-setpoint.jsonl.gz",
"bytes_raw": 265543,
"sha256_raw": "308ab0767d0dfd1c78daf313aef292e4383cd78d155656f2e829b88ad8539ece"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 116,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-15-soc.jsonl.gz",
"bytes_raw": 6372,
"sha256_raw": "5a8d8bdca4c6eccc275edcc13ee1b1ec24092f86d209b605f4405d95f23107d6"
}
}
},
{
"date": "2026-09-16",
"window": [
"2026-09-16T00:00:00+02:00",
"2026-09-17T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15888,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-grid.jsonl.gz",
"bytes_raw": 886214,
"sha256_raw": "cc3a1fc8f892fd22bb27b64f9928dcbbea9849a9c26ef8e7b2221483c82e5c15"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 4444,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-batt.jsonl.gz",
"bytes_raw": 252502,
"sha256_raw": "70189159b45c26f742dca92475d991f70210b5766a711e466b2fbd266968842b"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 5424,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-setpoint.jsonl.gz",
"bytes_raw": 309293,
"sha256_raw": "9f8c00b6bdea1de540f7ade4bc31870f61d972dfcd58d48a1653b1e114320d75"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 155,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-16-soc.jsonl.gz",
"bytes_raw": 8517,
"sha256_raw": "1bf7f07c5b60eea5cadb9758a40d2b34463ecaf33627b117ea2bed6afa45fb57"
}
}
},
{
"date": "2026-09-17",
"window": [
"2026-09-17T00:00:00+02:00",
"2026-09-18T00:00:00+02:00"
],
"files": {
"grid": {
"read": "OK",
"entity": "sensor.p1_meter_active_power",
"samples": 15544,
"non_numeric_states": 0,
"attempts": 1,
"file": "2026-09-17-grid.jsonl.gz",
"bytes_raw": 867466,
"sha256_raw": "340bec5e35fed7a4f0964694bbca294fba21fce77ddd27af616720398c1cbbc4"
},
"batt": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"samples": 3868,
"non_numeric_states": 2,
"attempts": 1,
"file": "2026-09-17-batt.jsonl.gz",
"bytes_raw": 218642,
"sha256_raw": "4cb8e89baa89b4919d5e9b578585ab591eac4e94a636fa3aa88d293926f8401b"
},
"setpoint": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"samples": 4599,
"non_numeric_states": 2,
"attempts": 1,
"file": "2026-09-17-setpoint.jsonl.gz",
"bytes_raw": 261144,
"sha256_raw": "ddebbc60d2d8b0b8e06d2bef6bc3b61ea7c39becb22f25b7b2219455a7398519"
},
"soc": {
"read": "OK",
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
"samples": 97,
"non_numeric_states": 2,
"attempts": 1,
"file": "2026-09-17-soc.jsonl.gz",
"bytes_raw": 5336,
"sha256_raw": "f1fade3d84a924447659fefa680f5b795266f3281bc2f5d5b6f86a1e434ddd4e"
}
}
}
]
}
+72 -21
View File
@@ -522,7 +522,20 @@ for _ in range(20):
pinned = [r for r in rows if r[3] == "saturated-freeze"]
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
pinned and all(r[1] == -800.0 for r in pinned))
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
# ⚠️ SAFETY-48 MOVED THIS NUMBER FROM -2390 W TO -1200 W, DELIBERATELY, AND
# THE PROPERTY IT IS HERE FOR IS UNCHANGED. The release lets the command walk
# to the FROZEN INTEGRATOR; SAFETY-48 bounds how far that integrator can have
# wound ahead of the command before the freeze engaged, so the value it walks
# to is smaller. What this run must still show is that the release happens and
# that it stops at the integrator - both asserted, the second against `i_w`
# rather than against a literal, because the literal is the half this card
# moves. The cost is stated rather than smoothed: on a persistent second writer
# this run buys back 1200 W of a 2000 W export where release/1.0 bought back
# 2390 W. DECISIONS/SAFETY-48 §5 records it as the price of the bound and the
# rejected alternative that avoids it.
check(f"...then released, settling at {prev:.0f} W, at the frozen integrator "
f"({i_w:.0f} W) - release/1.0 settled at -2390 W, at its own unbounded "
f"one", prev == -1200.0 and prev == i_w)
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
@@ -546,9 +559,14 @@ check(f"the card's case: the charge starts at cycle {started[0]}"
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
# does not release the INTEGRATOR freeze - so the command can only walk, one
# slew step per cycle, to a value the integrator stopped accumulating at.
# ⚠️ SAFETY-48 MOVED THIS ONE TOO, -1190 W -> -800 W against an integrator that
# stood at -1194 W and now stands at -800 W, for the reason above. AC 4's
# property - the release stops at the integrator and not at the rail - is what
# is asserted; the literal is the branch's own number and is printed.
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail - release/1.0 settled "
f"at -1190 W against an integrator of -1194 W",
prev == -800.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
check(f"the card's case: no cycle moves more than the slew limit "
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
@@ -1115,25 +1133,58 @@ check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail remove
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
and _R["interim"]["big"] == 0)
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
# integrator accumulates `gain * error` per cycle and is bounded only by
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
# not reduce it - on this model it gets slightly WORSE, because the command
# spends longer away from where it needs to be while the integrator keeps
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
# 863 W at gain 0.3 (tools/slew_sim.py).
# --- SAFETY-48 RE-BASELINED THIS CHECK, AND THE INVERSION IS THE EVIDENCE ----
#
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
# of them moving in one release makes the next measurement uninterpretable.
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
"slew from 1000 to 250 does not reduce the over-absorption at all",
_R["slower"]["over"] >= _R["live"]["over"])
# ⚠️ WHAT THIS LINE USED TO SAY, AND WHY IT IS NOT A SILENT EDIT. SAFETY-39
# shipped:
#
# "⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION ... Quartering the slew
# from 1000 to 250 does not reduce the over-absorption at all"
# asserted as _R["slower"]["over"] >= _R["live"]["over"]
#
# and the reasoning under it was right about the mechanism and therefore wrong
# about the conclusion: `slew_w` bounds the OUTPUT while the integrator
# accumulates `gain * error` and is bounded only by `integrator_max_w`, so
# slowing the slew leaves the integrator winding for LONGER and the
# over-absorption got WORSE. Measured on this model at release/1.0
# (`68c5ede`), worst case over the same 51-step family:
#
# slew_w 1000 over-absorbed 1005 W
# slew_w 0 (rail, 556) 1109 W <- SLOWER IS WORSE
# slew_w 400 1285 W
# slew_w 250 1330 W
#
# SAFETY-48 bounds the integrator's LEAD at one slew step, which is exactly the
# quantity that sentence describes. The same six runs on this branch:
#
# slew_w 1000 over-absorbed 729 W
# slew_w 0 (rail, 556) 550 W <- AND NOW SLOWER IS BETTER
# slew_w 400 424 W
# slew_w 250 326 W
#
# So the claim is inverted rather than weakened, on a plant model this file
# writes itself and SAFETY-48's 15.7-day replay of the live install's recorded
# disturbances has never seen. The two agree on the sign, which is worth more
# than either alone. ⚠️ The old direction is asserted as a NEGATIVE below so
# that a revert of SAFETY-48 cannot quietly restore it and leave this section
# green on the other law.
#
# `gain` is still the next constant to re-derive, and SAFETY-48 CHANGED NOTHING
# ABOUT IT: it is a live control-law constant on a running house, and two of
# them moving in one release makes the next measurement uninterpretable.
check("⭐ SAFETY-48: WITH THE INTEGRATOR'S LEAD BOUNDED, A SLOWER SLEW REDUCES "
"THE OVER-ABSORPTION - 1000 -> rail -> 400 -> 250 falls monotonically, "
f"{_R['live']['over']:.0f} -> {_R['rail']['over']:.0f} -> "
f"{_R['interim']['over']:.0f} -> {_R['slower']['over']:.0f} W. On "
"release/1.0 the same four runs RISE: 1005 -> 1109 -> 1285 -> 1330 W",
_R["rail"]["over"] < _R["live"]["over"]
and _R["interim"]["over"] < _R["rail"]["over"]
and _R["slower"]["over"] < _R["interim"]["over"])
check("...and every member of the family over-absorbs LESS than release/1.0's "
"own figure for that member - the recorded four are 1005, 1109, 1285 and "
"1330 W, taken on 68c5ede by running this file",
_R["live"]["over"] < 1005.0 and _R["rail"]["over"] < 1109.0
and _R["interim"]["over"] < 1285.0 and _R["slower"]["over"] < 1330.0)
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
"the same slew. That is the measurement behind 'gain is the next constant "
"to re-derive', and it is a measurement rather than an opinion",
+214 -28
View File
@@ -1,4 +1,9 @@
"""Runnable check for TEL-10: the loop's integrator, published.
"""Runnable check for TEL-10 and SAFETY-48. `python3 test_telemetry.py`
TEL-10 publishes the loop's integrator. SAFETY-48 bounds it. They are graded in
one file because they are one observable: the number SAFETY-48 bounds is the
number TEL-10 added, and a check on either that could not see the other would
be grading its own fixture.
`self.i_w` is carried between cycles of `Controller.cycle()` and, before this
card, left the process nowhere at all - no entity, no MQTT attribute, no log
@@ -71,6 +76,29 @@ REACH THE FAILURE THEY NAMED. Both are MUTATION.md habit 4:
It stops the controller part-way through a run that has already wound the
integrator to -5000 W, so a row left standing is a WRONG row.
SAFETY-48's OWN BATTERY, same discipline, against `app/control.py`:
# mutation bytes reds
-- ---------------------------------------------- ----- --------------------
L1 the whole SAFETY-48 block deleted -195 B 5 here + 4 control
L2 `SLEW_LEAD_STEPS = 0` - the velocity form +0 B 4 here + 3 control
L3 `SLEW_LEAD_STEPS = 12` - a cap above the rail +1 B 4 here + 3 control
L4 the cap applied unconditionally, outside the -92 B 1 here ⚠️ SURVIVOR
slew branch of all 30 files
until part 4's
`integrator_max_w`
case existed
L5 `min`/`max` swapped - the cap on the wrong +0 B 2 here + 5 control
side
L6 the lead measured against `prev_w` rather than +0 B 4 here + 3 control
the command it emits
⚠️ L4, L5 AND L6 ARE ALL ZERO- OR NEGATIVE-BYTE EDITS THAT CHANGE NO PAYLOAD
AND NO OPTION, AND L4 WAS BIT-IDENTICAL ON THE SHIPPED CONFIGURATION - 0 of
256 246 ticks over the 16-day recording. It is separable on 2050 of 257 400
swept states, all at `integrator_max_w` above `max_w`, which is the stimulus
part 4 drives.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
@@ -83,7 +111,7 @@ import asyncio
import sys
from app import mqtt
from app.control import Tuning, slew_limit_w
from app.control import SLEW_LEAD_STEPS, Tuning, compute, slew_limit_w
from app.main import Controller
fails = []
@@ -203,12 +231,16 @@ def run(grid, cycles=30, auto=True, stop_after=None, **over):
# ==============================================================================
print("1. TEL-10: the integrator is published, and it is NOT the command")
# ==============================================================================
# A standing 6 kW export against a 5 kW rail and a 400 W slew step. The
# integrator moves `gain * error` = 3.6 kW on the first cycle and reaches its
# 5000 W bound on the second; the command may only move 400 W a cycle, so it is
# at 800 W when the integrator is already railed and needs twelve more cycles to
# get there. That gap is the fixture: for ten cycles the two numbers differ by
# kilowatts, and a row published from the wrong one is wrong by kilowatts.
# A standing 6 kW export against a 5 kW rail and a 400 W slew step.
#
# ⚠️ THIS FIXTURE WAS BUILT AGAINST THE UNBOUNDED INTEGRATOR AND SAFETY-48 TOOK
# ITS SEPARATION AWAY, WHICH IS WORTH SAYING RATHER THAN QUIETLY RE-TUNING. On
# the law TEL-10 shipped against, the integrator reached its 5000 W bound on
# cycle 1 and the command needed twelve more cycles to arrive, so the two rows
# differed by KILOWATTS and any check at all could see it. SAFETY-48 bounds the
# lead at one slew step, so the gap is now 800 W and the integrator arrives ONE
# cycle early instead of twelve. Both surviving separations are graded: the
# ARRIVAL ORDER, and the 800 W plateau that holds for the whole ramp.
ctl, hass, frames = run(-6000.0, cycles=30)
i_row = [f["loop_i"] for f in frames]
sp_row = [f["setpoint"] for f in frames]
@@ -226,30 +258,43 @@ print(f" the integrator reaches {-BOUND_W:.0f} W at cycle "
f"{first_at_bound}; the command reaches it at cycle "
f"{first_cmd_at_bound}")
# ⚠️ THE LEAD TIME IS PART OF THE CLAIM, NOT A NICETY. "The published number
# ⚠️ THE ARRIVAL ORDER IS THE CLAIM, NOT THE VALUE. "The published number
# reaches the bound" is satisfied by publishing the COMMAND too - it gets there
# as well, twelve cycles later - so a check written on the value alone grades
# nothing and the mutation that publishes `prev_w` walks straight through it.
# Measured: with `self.loop_i_w = loop_prev` this line is red and the value-only
# form was green.
check("⭐ THE PUBLISHED INTEGRATOR REACHES ITS BOUND, AND REACHES IT TEN CYCLES "
f"BEFORE THE COMMAND DOES. A 6 kW export winds it to exactly "
f"{BOUND_W:.0f} W - `integrator_max_w` is None, which control.py resolves "
"to `max_w` - while the command is still travelling 400 W a cycle",
# as well, a cycle later - so a check written on the value alone grades nothing
# and the mutation that publishes `prev_w` walks straight through it. Measured:
# with `self.loop_i_w = loop_prev` the value-only form was GREEN and this one is
# red.
check("⭐ THE PUBLISHED INTEGRATOR REACHES ITS BOUND, AND REACHES IT BEFORE THE "
f"COMMAND DOES. A 6 kW export winds it to exactly {BOUND_W:.0f} W - "
"`integrator_max_w` is None, which control.py resolves to `max_w` - while "
"the command is still a slew step short",
first_at_bound is not None and i_row[first_at_bound] == -BOUND_W
and first_cmd_at_bound - first_at_bound >= 10)
and first_cmd_at_bound > first_at_bound)
# ⚠️ THE PRECONDITION, AS ITS OWN CHECK (habit 1). If the command happened to be
# at the bound too, the line above would be satisfied by publishing EITHER
# number and would be grading nothing.
check("...and the command is NOWHERE NEAR it at that tick, so the two are "
check("...and the command is NOT at the bound at that tick, so the two are "
f"separable: setpoint {sp_row[first_at_bound]:.0f} W against an "
f"integrator of {i_row[first_at_bound]:.0f} W",
abs(sp_row[first_at_bound]) <= BOUND_W / 2)
check("...and the gap is the slew limit and not an accident: the command is "
"still travelling 400 W a cycle while the integrator stands still",
abs(sp_row[first_at_bound + 1] - sp_row[first_at_bound])
== slew_limit_w(Tuning(**LIVE)))
abs(BOUND_W - abs(sp_row[first_at_bound])) >= 200.0)
# ⚠️ AND THE PLATEAU, WHICH IS THE SEPARATION THAT LASTS. Through the whole
# ramp the integrator stands exactly two slew steps ahead of the command the
# previous cycle left standing - one step is `SLEW_LEAD_STEPS` against the
# command this cycle EMITS, and the emitted command is itself one step ahead of
# `prev_w`. It is graded as a RUN of identical values rather than as a maximum,
# because a maximum is reached by one tick and a plateau is the shape.
_plateau = [k for k in range(1, 13) if lead_row[k] == -800.0]
print(f" the lead plateau: {lead_row[1:13]}")
check("⭐ AND THE TWO ROWS ARE SEPARATED FOR THE WHOLE RAMP, not at one tick: "
f"the lead sits at exactly -800 W (two slew steps) on "
f"{len(_plateau)} of cycles 1..12 while both numbers travel 400 W a "
"cycle", len(_plateau) >= 8)
check("...and the command really is travelling a full slew step a cycle "
"through that plateau, so the plateau is not a stalled loop",
all(round(abs(sp_row[k] - sp_row[k - 1]), 6)
== slew_limit_w(Tuning(**LIVE)) for k in _plateau))
# The lead is `i_w - prev_w`, and `prev_w` is the command the LAST cycle left
# standing - which is the setpoint this file captured one frame earlier. Graded
@@ -260,9 +305,9 @@ ident = [k for k in range(1, 12)
check("⭐ AND `loop_lead` IS THE INTEGRATOR MINUS THE COMMAND IT WAS COMPUTED "
"AGAINST - the PREVIOUS cycle's setpoint, not this one's. Checked as an "
f"identity over cycles 1..11 ({len(ident)} disagreements)", not ident)
check("...and it is large while the command is catching up, so the row is not "
f"a permanent zero: worst lead {min(lead_row):.0f} W",
min(lead_row) <= -2000.0)
check("...and it is never a permanent zero while the loop is working: worst "
f"lead {min(lead_row):.0f} W",
min(lead_row) <= -800.0)
# ==============================================================================
@@ -312,7 +357,7 @@ check("a STOPPED controller in shadow mode still publishes a live integrator - "
"the decision path runs, so the row must report it rather than a zero "
"that would read as 'nothing is winding'",
shadowed[-1]["status"] == "shadow"
and shadowed[-1]["loop_i"] < -1000.0
and shadowed[-1]["loop_i"] < -100.0
and shadowed[-1]["loop_reason"] != "not-running")
# ⚠️ AND IT IS STOPPED PART-WAY, NOT STARTED STOPPED, WHICH IS THE WHOLE
@@ -360,6 +405,147 @@ check("...and they are on the SITE device, not per unit - there is one "
all(c["device"] == mqtt.DEVICE
for t, c in mqtt.discovery_payloads(["inverter-a", "inverter-b"])
if c["unique_id"].replace(f"{mqtt.BASE}_", "") in TEL10))
# ==============================================================================
print()
print("4. SAFETY-48: the slew step ceiling stops the integrator outrunning it")
# ==============================================================================
# ⚠️ THE ARITHMETIC THE CARD IS ABOUT, RE-DERIVED RATHER THAN QUOTED. 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`. At the live shape that is 667 W, and the recorded
# median error at the onset of an episode is 1114 W.
T = Tuning(**LIVE)
check(f"the outrun threshold is `slew_w / gain` = {OUTRUN_W:.0f} W, and it is "
"the shipped pair that produces it, not a constant written here",
abs(T.slew_w / T.gain - OUTRUN_W) < 0.5)
# ⚠️ TWO LEADS, AND CONFUSING THEM IS HOW THIS CHECK WOULD BE WRITTEN WRONG.
# The bound is on `i_w - target`, the command this cycle EMITS - that is what
# makes the unwind one cycle, because the command can cover one slew step in
# one cycle. `loop_lead`, and `Decision.i_w - prev_w`, are measured against the
# command the PREVIOUS cycle left standing, and the emitted command is itself
# one slew step further on - so the published figure is bounded by TWO steps,
# not one. Both are asserted, against their own bound.
_SLEW = slew_limit_w(T)
d = compute(prev_w=0.0, grid_w=-6000.0, actual_w=0.0, tuning=T, i_w=-100.0)
print(f" one cycle at a 5990 W error: command {d.target_w:+.0f} W, "
f"integrator {d.i_w:+.0f} W, ahead of the EMITTED command by "
f"{d.i_w - d.target_w:+.0f} W and of `prev_w` by {d.i_w:+.0f} W, "
f"reason {d.reason!r}")
check(f"⭐ THE INTEGRATOR MAY LEAD THE COMMAND IT EMITS BY AT MOST "
f"{SLEW_LEAD_STEPS} x slew_w = {SLEW_LEAD_STEPS * _SLEW:.0f} W - so the "
"worst-case unwind is ONE cycle, not `integrator_max_w / slew_w` (12.5 "
"at this shape)",
abs(d.i_w - d.target_w) <= SLEW_LEAD_STEPS * _SLEW + 1e-9)
# ...and over a real run, which is where release/1.0 walks to the rail.
leads = [abs(f["loop_lead"]) for f in frames]
print(f" over {len(frames)} driven cycles: worst published lead "
f"{max(leads):.0f} W, worst integrator {min(i_row):.0f} W")
check(f"⭐ AND IT HOLDS ON EVERY DRIVEN CYCLE: the published lead - measured "
f"against the PREVIOUS command - never exceeds "
f"{(SLEW_LEAD_STEPS + 1) * _SLEW:.0f} W, one step for the bound and one "
"for the step the command took. On release/1.0 this same run reaches "
"4600 W",
max(leads) <= (SLEW_LEAD_STEPS + 1) * _SLEW + 1e-9)
check("...and release/1.0's figure is not merely larger, it is over the bound: "
"the run that produced 4600 W here would be red on the line above",
4600.0 > (SLEW_LEAD_STEPS + 1) * _SLEW)
check("...and nothing here is the SATURATION freeze doing SAFETY-48's work: "
"the inverter tracks exactly, so `frozen` is never true",
not any(f["loop_reason"] in ("saturated-freeze", "freeze-released")
for f in frames))
# ⚠️ AND THE CAP IS NOT ZERO, WHICH IS ITS OWN CHECK BECAUSE NOTHING ELSE
# NOTICES. `i_w = slewed` - a cap of zero - satisfies every bound above and is
# what control.py's docstring calls "the exact velocity form it replaced":
# the accumulator stops being a separate quantity at all. The claim is
# therefore that the integrator is STRICTLY ahead of the command it emits, by
# exactly one step, on a slew-limited cycle.
check(f"⭐ AND IT IS NOT A BACK-CALCULATION: on a slew-limited cycle the "
f"integrator stands EXACTLY {SLEW_LEAD_STEPS} slew step(s) ahead of the "
f"command it emits ({d.i_w - d.target_w:+.0f} W), not on top of it - "
"a cap of zero would collapse this function to the velocity form it "
"replaced",
d.reason == "slew-limited"
and round(abs(d.i_w - d.target_w), 6) == SLEW_LEAD_STEPS * _SLEW)
# ⚠️ AND IT BINDS IN BOTH DIRECTIONS OF TRAVEL, WHICH IS NOT OBVIOUS FROM THE
# SPELLING. `if target > slewed ... elif target < slewed` reads like two cases
# and is one: a command FALLING from +2400 W toward an integrator at +100 W has
# an integrator 2300 W ahead of it in the direction of travel, and it is capped
# exactly as a rising one is. A check written only on the rising case would be
# green for a gate that had lost half of itself.
_dn = compute(prev_w=2400.0, grid_w=-200.0, actual_w=2400.0, tuning=T,
sat_count=0, i_w=100.0)
print(f" descending: prev +2400 W, integrator +100 W -> command "
f"{_dn.target_w:+.0f} W, integrator {_dn.i_w:+.0f} W, "
f"reason {_dn.reason!r}")
check("⭐ THE CAP BINDS ON A FALLING COMMAND TOO: descending from +2400 W the "
f"integrator is pulled to exactly one slew step beyond the command it "
f"emits ({_dn.i_w - _dn.target_w:+.0f} W), not left 2300 W below it",
_dn.reason == "slew-limited"
and round(abs(_dn.i_w - _dn.target_w), 6) == SLEW_LEAD_STEPS * _SLEW)
# ⚠️ THE CAP IS INSIDE THE SLEW BRANCH, AND THIS IS THE ONLY STIMULUS THAT CAN
# TELL THAT FROM AN UNCONDITIONAL ONE. Applying it to every cycle - `i_w =
# max(target - cap, min(target + cap, i_w))` after `target = slewed` - is a
# -92 B edit that is BIT-IDENTICAL on the shipped configuration, and it was a
# SURVIVOR of all 30 files until this check existed. It is measured: 0 of
# 256 246 ticks differ over the 16-day recording, and 2050 of 257 400 swept
# states do - every one of them at `integrator_max_w` 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 a
# cap that fired on every cycle would drag it back to the command and make the
# option inert.
_HI = Tuning(gain=0.6, max_w=2000.0, slew_w=400.0, target_grid_w=-10.0,
integrator_max_w=5000.0)
_hi = compute(prev_w=-2400.0, grid_w=-8000.0, actual_w=-6000.0, tuning=_HI,
sat_count=0, i_w=-2400.0)
print(f" integrator_max_w 5000 above a max_w of 2000: command "
f"{_hi.target_w:+.0f} W, integrator {_hi.i_w:+.0f} W, "
f"reason {_hi.reason!r}")
check("⭐ THE CAP DOES NOT FIRE WHERE THE SLEW DID NOT BIND: with "
"`integrator_max_w` deliberately above `max_w` the integrator keeps its "
"own bound (-5000 W) instead of being dragged to the command - an "
"unconditional cap would put it at -2400 W and make the option inert",
_hi.i_w == -5000.0 and _hi.reason == "clamped")
# ⚠️ THE TWO RECORDED DEADLOCKS, AS TEST CASES. control.py names both at
# length; a gate written on the integrator is exactly the edit that brings them
# back, so each is driven rather than argued.
#
# (a) THE `i_w == 0.0` CASE. `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. A gate that files zero under
# one sign blocks the first push toward the other one forever.
z = compute(prev_w=0.0, grid_w=-6000.0, actual_w=0.0, tuning=T, sat_count=0,
i_w=0.0)
check("at `i_w == 0.0` the first push toward charging is NOT blocked - the "
f"command leaves zero ({z.target_w:+.0f} W) and the integrator moves "
f"({z.i_w:+.0f} W)", z.target_w < 0.0 and z.i_w < 0.0)
# (b) 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. Driven
# as the measured case: 0 W held into a 2 kW import.
imp = run(2000.0, cycles=12)[2]
print(f" 2 kW import from rest: setpoints "
f"{[f['setpoint'] for f in imp[:6]]}")
check("a 2 kW import from rest recovers rather than sitting at its last value "
"- the shrink-only encoding held 0 W indefinitely here",
imp[-1]["setpoint"] >= 1000.0)
# ⚠️ AND THE GATE MUST NOT BE A DELAY. A loop that is slew-limited for a long
# ramp has to arrive, or the fix has bought export back with standing error.
far = run(-6000.0, cycles=40)[2]
print(f" a 6 kW export, 40 cycles: setpoint reaches "
f"{far[-1]['setpoint']:.0f} W of the {-BOUND_W:.0f} W rail")
check("⭐ THE COMMAND STILL ARRIVES: a railed export drives the command to the "
"rail, one slew step a cycle, with the integrator bounded the whole way "
"- the gate bounds the LEAD, it does not stop the loop",
far[-1]["setpoint"] == -BOUND_W)
print()
if fails:
@@ -0,0 +1,649 @@
#!/usr/bin/env python3
"""SAFETY-48's evidence: the live install's OWN recorded disturbances, replayed
through the shipped control law with and without the card.
python3 tools/replay_recorded_loop.py # the A/B table
python3 tools/replay_recorded_loop.py --sensitivity # ...and does it survive
# a different plant?
python3 tools/replay_recorded_loop.py --export --days 16 --token $HASS_TOKEN
WHY A REPLAY AND NOT A STEP RESPONSE
====================================
`test_control.py`'s SAFETY-39 section drives a family of synthetic export steps
through a first-order plant, and it is a good instrument for the question it
asks. It cannot answer this one. The defect this card is about is a MISSING
CASE - the integrator outrunning the command whenever `|error| > slew_w / gain`
- and whether that case matters is a question about how often the house
actually produces such an error, how long it stands, and what else is happening
when it does. A step family answers "what would happen if"; only the recording
answers "how much did this cost".
WHAT IS REAL IN HERE
====================
* THE DISTURBANCE. `D = grid + batt` at every recorded meter instant: the
house's own net demand with THIS loop's battery power taken back out. It is
exogenous - the house does not know what the battery did - which is what
makes it valid to drive a counterfactual with, for a run of any length.
* THE TICK. The replay steps on the METER's own recorded sample instants, not
on a constant. Median 5.0 s over the window. ⚠️ `control.LOOP_TICK_S` says
2.0 s; that was measured on 2026-09-05 and is NOT what this install has been
running since. The figure matters to `SLEW_RAIL_FRAC`, and it is wrong in
the SAFE direction (a slower meter makes validate.py rule 10 conservative).
* THE CONTROL LAW. `app/control.py`, imported out of this 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 - so it is release/1.0's
behaviour derived from the shipped bytes, never a transcription. The
substitution anchor must occur exactly once or this refuses to run.
MODELLED, AND THEREFORE NOT EVIDENCE ABOUT THE THING MODELLED
=============================================================
* THE INVERTER. A pure delay + first-order lag + affine gain, FITTED on this
same recording at SoC above the floor. Its residual is printed on every run
and is large (~375 W RMS): the replay tracks the meter WORSE than the real
install does, so the absolute export figures below are pessimistic by about
a third against the recording. ⚠️ THE DELTA IS WHAT THIS TOOL IS FOR - both
laws run against the same plant, on the same ticks, from the same seed.
* THE PACK. SoC integrated from the simulated battery power off the recorded
SoC, discharge refused at or below the floor, charge refused at 100 %.
* EVERYTHING ELSE THE ARBITER DOES. The recorded setpoint is the ARBITER's
output - the loop's claim after the peak guard, the SoC clamps, maintenance
and arbitrage have had their say. This replay runs `compute()` ALONE. That
is the largest single reason the fidelity section below is not tighter, and
it is why no absolute figure here should be quoted as "what the install
did"; the recording itself is that.
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
=======================================================================
* IT CANNOT SHOW THAT THE FIX IS SAFE ON THE REAL PLANT. Nothing here drives
an inverter. The rig and a live run are what answer that, and SAFETY-47
records why a shadow run cannot.
* IT CANNOT SEPARATE THE FIX FROM THE PLANT MODEL ON ONE RUN. `--sensitivity`
re-runs the whole table on five other plants and tunings; the ranking is the
claim, not the watt figures.
* THE BILLED QUANTITY IS A MAXIMUM OVER ~1500 QUARTERS, so it is a tail
statistic and one quarter decides it. The per-quarter MEAN delta is printed
beside it for exactly that reason.
"""
import argparse
import bisect
import gzip
import hashlib
import json
import math
import os
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
HERE = pathlib.Path(__file__).resolve().parent
ADDON = HERE.parent
sys.path.insert(0, str(ADDON))
SCENARIO = ADDON / "scenarios" / "loop-2026-09-02_2026-09-17"
DEFAULT_URL = "http://192.168.2.158:8123"
TZNAME = "Europe/Brussels"
# The entities, and what each is for. ⚠️ `setpoint` is NOT an input to the
# replay: it is the RECORDING the replay is graded against in §fidelity, and it
# is what `slew_w` and `max_w` are read off rather than assumed.
ENTITIES = {
"grid": "sensor.p1_meter_active_power",
"batt": "sensor.goodwe_rs485_controller_goodwe_battery_power",
"setpoint": "sensor.goodwe_rs485_controller_goodwe_setpoint",
"soc": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
}
# The live install's shape. `max_w` and `slew_w` are READ OFF THE RECORDING by
# `--export` and re-derived on every run below; `gain` and `target_grid_w` are
# the options and are not observable in the trace.
LIVE = dict(gain=0.6, max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
SOC_FLOOR = 10.0
CAP_WH = 17379.0 # measured: -dE/dSoC over 1604 segments of this record
GRID_HOLD_S = 30.0 # `meter_max_age_s`: past this a held meter is no reading
TICK_CAP_S = 60.0 # a longer gap is an outage, not a cycle
# ---------------------------------------------------------------------------
# the recording
def _rows(key):
out = []
for fn in sorted(os.listdir(SCENARIO)):
if not fn.endswith(f"-{key}.jsonl.gz"):
continue
with gzip.open(SCENARIO / fn, "rb") as fh:
for line in fh:
r = json.loads(line)
out.append((datetime.fromisoformat(r["t"]).timestamp(), r["v"]))
out.sort(key=lambda r: r[0])
ded = []
for t, v in out:
if ded and abs(ded[-1][0] - t) < 1e-9:
ded[-1] = (t, v) # the history endpoint's day-boundary repeat
continue
ded.append((t, v))
return ded
def _steps(key):
"""[(t, float|None)] - None OPENS A HOLE and nothing is held across it.
⚠️ A LONG SILENCE IS NOT A HOLE. Home Assistant's recorder stores a row only
when a state CHANGES, so eleven hours of `setpoint` silence means the
command held that value for eleven hours - which is exactly what this site
does overnight at its SoC floor. What IS a hole is a row whose state is
`unavailable`/`unknown`, and `grid` additionally stops being believed after
GRID_HOLD_S because that is the age the shipped controller stops believing
it at.
"""
out = []
for t, s in _rows(key):
try:
out.append((t, float(s)))
except (TypeError, ValueError):
out.append((t, None))
return out
def stream():
"""[(t, grid, batt, setpoint, soc)] at every recorded METER instant."""
g = [(t, v) for t, v in _steps("grid") if v is not None]
others = {}
for k in ("batt", "setpoint", "soc"):
s = _steps(k)
others[k] = ([r[0] for r in s], [r[1] for r in s])
prev_t = None
out = []
for t, v in g:
if prev_t is not None and t - prev_t > GRID_HOLD_S:
prev_t = t
continue
prev_t = t
row = [t, v]
for k in ("batt", "setpoint", "soc"):
tt, vv = others[k]
j = bisect.bisect_right(tt, t) - 1
row.append(None if j < 0 else vv[j])
if any(x is None for x in row):
continue
out.append(tuple(row))
return out
# ---------------------------------------------------------------------------
# the plant, fitted on this same recording
def _lsq(xs, ys):
n = len(xs)
mx, my = sum(xs) / n, sum(ys) / n
sxy = sum((a - mx) * (b - my) for a, b in zip(xs, ys))
sxx = sum((a - mx) ** 2 for a in xs)
syy = sum((b - my) ** 2 for b in ys)
return sxy / sxx, my - (sxy / sxx) * mx, sxy / math.sqrt(sxx * syy)
def fit_plant(rows, stride=1):
"""(lag_s, tau_s, gain, offset, rms_W), fitted where the pack can MOVE.
⚠️ THE SoC FILTER IS THE WHOLE FIT. 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, and a fit taken over all ticks reads
`batt = 0.50 x 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 x cmd - 28 W, r = 0.977`. The floor is the PACK and is modelled
separately.
"""
t = [r[0] for r in rows]
ba = [r[2] for r in rows]
sp = [r[3] for r in rows]
live = [r[4] > SOC_FLOOR + 2 for r in rows]
best = None
for lag in (0.0, 2.5, 5.0, 7.5, 10.0, 12.5):
j, u = 0, []
for i, ti in enumerate(t):
while j + 1 < len(t) and t[j + 1] <= ti - lag:
j += 1
u.append(sp[j])
idx = [i for i in range(0, len(t), stride)
if live[i] and abs(u[i]) > 50]
g, o, _ = _lsq([u[i] for i in idx], [ba[i] for i in idx])
for tau in (0.5, 1.0, 2.0, 4.0, 8.0, 16.0):
b, err, cnt = 0.0, 0.0, 0
for i in range(1, len(t)):
dt = min(t[i] - t[i - 1], TICK_CAP_S)
b += (1.0 - math.exp(-dt / tau)) * (g * u[i] + o - b)
if live[i]:
err += (b - ba[i]) ** 2
cnt += 1
rms = math.sqrt(err / cnt)
if best is None or rms < best[-1]:
best = (lag, tau, g, o, rms)
return best
class Plant:
def __init__(self, lag_s, tau_s, g, o, soc0):
self.lag_s, self.tau_s, self.g, self.o = lag_s, tau_s, g, o
self.b, self.soc, self.q = 0.0, soc0, []
def seed(self, t, b, cmd):
self.b, self.q = b, [(t - self.lag_s - 1e-6, cmd)]
def step(self, t, dt, cmd):
self.q.append((t, cmd))
want = self.q[0][1]
while len(self.q) > 1 and self.q[1][0] <= t - self.lag_s:
self.q.pop(0)
want = self.q[0][1]
tgt = self.g * want + self.o
if self.soc <= SOC_FLOOR:
tgt = min(tgt, 0.0) # an empty pack cannot discharge
if self.soc >= 100.0:
tgt = max(tgt, 0.0) # a full one cannot charge
self.b += (1.0 - math.exp(-dt / self.tau_s)) * (tgt - self.b)
self.soc = max(0.0, min(100.0, self.soc - self.b * dt / 36.0 / CAP_WH))
return self.b
# ---------------------------------------------------------------------------
# the two laws
SAFETY_48_BLOCK_ANCHOR = " _lead_cap = SLEW_LEAD_STEPS * slew\n"
def laws():
"""(before, after) - the shipped module, and the same SOURCE without the card.
⚠️ NOT A TRANSCRIPTION, AND NOT A FLAG. `test_mqtt.py` part 8's technique:
the shipped file's bytes with one named substitution, exec'd into a fresh
namespace. An anchor that does not occur exactly once is a refusal, because
a substitution that applied to nothing would be reported as "the card
changes nothing" - MUTATION.md's survivor, wearing an instrument's clothes.
"""
import app.control as after
src = (ADDON / "app" / "control.py").read_text()
if src.count(SAFETY_48_BLOCK_ANCHOR) != 1:
raise SystemExit(
"replay_recorded_loop: app/control.py does not contain exactly one\n"
f" {SAFETY_48_BLOCK_ANCHOR.strip()!r}\n"
"NOTHING HAS BEEN MEASURED. This is a REFUSAL, not a result: with no\n"
"anchor there is no `before` law to compare against, and a run that\n"
"quietly compared the shipped law with ITSELF would report the card\n"
"as changing nothing - which is the survivor MUTATION.md is about,\n"
"wearing an instrument's clothes.")
start = src.index(SAFETY_48_BLOCK_ANCHOR)
end = src.index(" target = slewed\n", start)
block = src[start:end]
stripped = src.replace(block, "")
if "SLEW_LEAD_STEPS * slew" in stripped:
raise SystemExit("replay_recorded_loop: the block was not removed - "
"NOTHING HAS BEEN MEASURED")
before = type(sys)("control_before")
exec(compile(stripped, "<release/1.0 law>", "exec"), before.__dict__)
return before, after
def replay(rows, law, fit, tuning):
"""[(t, dt, grid, batt, cmd, i_w, lead, reason, soc)] over the whole record."""
t0, g0, b0, sp0, soc0 = rows[0]
pl = Plant(*fit[:4], soc0)
pl.seed(t0, b0, sp0)
prev, i_w, sat = sp0, sp0, 0
out = []
for i in range(1, len(rows)):
t, gr, ba, sp, soc = rows[i]
dt = t - rows[i - 1][0]
if dt > TICK_CAP_S:
# An outage. Re-seed from the recording rather than inventing a
# cycle: nothing this loop did across it is knowable.
pl = Plant(*fit[:4], soc)
pl.seed(t, ba, sp)
prev, i_w, sat = sp, sp, 0
continue
D = gr + ba # the exogenous half of the meter
b = pl.step(t, dt, prev)
grid = D - b
d = law.compute(prev, grid, b, tuning, sat, i_w)
out.append((t, dt, grid, b, d.target_w, d.i_w, d.i_w - prev, d.reason,
pl.soc))
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
return out
# ---------------------------------------------------------------------------
# the five reported quantities - ONE implementation, run on both traces
def export_kwh(tr):
return sum(max(0.0, -r[2]) * r[1] for r in tr) / 3.6e6
def import_kwh(tr):
return sum(max(0.0, r[2]) * r[1] for r in tr) / 3.6e6
def reversals(tr, dead_w=50.0):
n, last = 0, 0
for r in tr:
s = 1 if r[3] > dead_w else (-1 if r[3] < -dead_w else 0)
if s and last and s != last:
n += 1
if s:
last = s
return n
def wasted_kwh(tr, bin_s=60.0):
"""Throughput that went round rather than anywhere, in FIXED clock bins.
Per bin, `sum |b|dt - |sum b dt|`. A bin spent charging, or discharging, or
idle contributes ZERO; only a bin that does both contributes. ⚠️ The bins
are wall-clock and identical for both laws on purpose: an episode-scoped
definition would MOVE with the fix and compare two different questions.
"""
tot, cur, absE, net = 0.0, None, 0.0, 0.0
for r in tr:
k = math.floor(r[0] / bin_s)
if cur is None:
cur = k
if k != cur:
tot += abs(absE) - abs(net)
cur, absE, net = k, 0.0, 0.0
absE += abs(r[3]) * r[1]
net += r[3] * r[1]
return (tot + abs(absE) - abs(net)) / 3.6e6
def quarters(tr):
"""{quarter -> mean OFFTAKE in W}. The billed quantity.
Calendar quarter-hours, the average of NET power, floored at 0 - a quarter
that nets to export is not offtake and is not billed. A quarter with less
than 80 % coverage is not a quarter and is dropped.
"""
acc = {}
for r in tr:
k = math.floor(r[0] / 900.0)
e, s = acc.get(k, (0.0, 0.0))
acc[k] = (e + r[2] * r[1], s + r[1])
return {k: max(0.0, e / s) for k, (e, s) in acc.items() if s > 720.0}
def recoveries(tr, bias, onset_w, cap_s=900.0):
"""Seconds from the error leaving `onset_w` to it back inside the deadband.
⚠️ AN EXCURSION THE PACK CANNOT ANSWER IS NOT A RECOVERY TIME. This install
spends 36 % of its ticks at the SoC floor with the command standing near
2 kW and the battery delivering nothing; the error there is not the loop's
to clear and every law scores the same non-answer. Leaving those in gave a
p99 of eight hours. An excursion counts only if the pack had headroom in the
needed direction for its whole length, and one still standing after `cap_s`
is counted as UNRECOVERED rather than as a large number.
"""
out, unrec, t_on, ok = [], 0, None, True
for r in tr:
err = r[2] - bias
head = (r[8] > SOC_FLOOR + 1) if err > 0 else (r[8] < 99.0)
if t_on is None:
if abs(err) > onset_w:
t_on, ok = r[0], head
else:
ok = ok and head
if abs(err) < 15.0:
if ok:
out.append(r[0] - t_on)
t_on = None
elif r[0] - t_on > cap_s:
unrec += 1 if ok else 0
t_on = None
return sorted(out), unrec
def pct(xs, p):
return xs[min(len(xs) - 1, int(round(p / 100.0 * (len(xs) - 1))))] if xs else float("nan")
def table(tr, tuning):
rec, unrec = recoveries(tr, tuning.target_grid_w, tuning.slew_w / tuning.gain)
leads = sorted(abs(r[6]) for r in tr)
q = quarters(tr)
return dict(export_kwh=export_kwh(tr), import_kwh=import_kwh(tr),
reversals=reversals(tr), wasted_kwh=wasted_kwh(tr),
worst_quarter_w=max(q.values()), rec_n=len(rec),
rec_med=pct(rec, 50), rec_p99=pct(rec, 99),
rec_max=rec[-1] if rec else float("nan"),
rec_unrecovered=unrec, lead_p99=pct(leads, 99),
lead_max=leads[-1], slew_ticks=sum(1 for r in tr if r[7] == "slew-limited"),
ticks=len(tr), _q=q)
ROWS = [("export_kwh", "export kWh", "the cost"),
("import_kwh", "import kWh", ""),
("reversals", "battery sign reversals", ""),
("wasted_kwh", "wasted throughput kWh", ""),
("worst_quarter_w", "worst 15-min offtake W", "⚠️ THE BILLED ONE"),
("rec_n", "recovery events", ""),
("rec_med", "recovery median s", ""),
("rec_p99", "recovery p99 s", ""),
("rec_max", "recovery max s", ""),
("rec_unrecovered", "unrecovered (>900 s)", ""),
("lead_p99", "|i_w - prev_w| p99 W", ""),
("lead_max", "|i_w - prev_w| max W", ""),
("slew_ticks", "slew-limited ticks", "")]
def show(a, b, name_a="release/1.0", name_b="SAFETY-48"):
print(f" {'':26} {name_a:>13} {name_b:>13} {'delta':>11}")
for k, lab, note in ROWS:
print(f" {lab:<26} {a[k]:13.2f} {b[k]:13.2f} {b[k] - a[k]:+11.2f}"
+ (f" {note}" if note else ""))
d = [b["_q"][k] - a["_q"][k] for k in a["_q"] if k in b["_q"]]
print(f" {'mean quarter delta W':<26} {'':13} {'':13} "
f"{sum(d) / len(d):+11.2f} over {len(d)} quarters")
print(f" {'worst quarter delta W':<26} {'':13} {'':13} {max(d):+11.2f}")
def fidelity(tr, rows):
"""What the BEFORE run gets wrong against the recording it came from.
⚠️ PRINTED BEFORE THE A/B TABLE IS READ, NOT AFTER. This is the number that
says how far any ABSOLUTE figure above may be trusted, and it is large.
"""
n = len(tr)
rec_g = [rows[i + 1][1] for i in range(n)]
rec_sp = [rows[i + 1][3] for i in range(n)]
sim_g = [r[2] for r in tr]
sim_c = [r[4] for r in tr]
dt = [r[1] for r in tr]
_, _, r_sp = _lsq(sim_c, rec_sp)
_, _, r_g = _lsq(sim_g, rec_g)
ex_sim = sum(max(0.0, -g) * d for g, d in zip(sim_g, dt)) / 3.6e6
ex_rec = sum(max(0.0, -g) * d for g, d in zip(rec_g, dt)) / 3.6e6
print(f" setpoint r={r_sp:.3f} mean {sum(sim_c) / n:+8.0f} W simulated "
f"vs {sum(rec_sp) / n:+8.0f} W recorded")
print(f" grid r={r_g:.3f} mean {sum(sim_g) / n:+8.0f} W simulated "
f"vs {sum(rec_g) / n:+8.0f} W recorded")
print(f" export {ex_sim:8.2f} kWh simulated vs {ex_rec:8.2f} kWh "
f"recorded -> the model is {100 * (ex_sim / ex_rec - 1):+.0f} % "
f"PESSIMISTIC about the loop's tracking")
# ---------------------------------------------------------------------------
# --export: the read-only capture
def _get(url, token, timeout=180.0):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
last = None
for attempt in range(1, 5):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
raise RuntimeError(f"HTTP {resp.status}")
return json.loads(resp.read().decode()), attempt
except Exception as exc:
last = f"{type(exc).__name__}: {exc}"
if attempt < 4:
time.sleep(3 * attempt)
raise RuntimeError(last)
def export(base, token, days, out_dir):
"""One local day per request, per entity. Read-only: GET only, never a write.
⚠️ URL-ENCODE THE TIMESTAMPS. The history endpoint answers `Invalid
end_time` otherwise, and it answers it with a 400 that reads like a bad
entity id.
"""
from zoneinfo import ZoneInfo
tz = ZoneInfo(TZNAME)
out_dir = pathlib.Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(tz).date()
man = {"generated": datetime.now(tz).isoformat(),
"generator": "tools/replay_recorded_loop.py --export",
"source": f"{base}/api/history/period", "timezone": TZNAME,
"entities": ENTITIES,
"format": '{"t": <ISO-8601 with offset>, "v": <state string>} per '
'line, gzip, mtime 0',
"days": []}
rc = 0
for d in range(days, 0, -1):
day = today - timedelta(days=d)
s = datetime(day.year, day.month, day.day, tzinfo=tz)
e = s + timedelta(days=1)
rec = {"date": str(day), "window": [s.isoformat(), e.isoformat()],
"files": {}}
for key, ent in ENTITIES.items():
q = urllib.parse.urlencode({"filter_entity_id": ent,
"end_time": e.isoformat(),
"minimal_response": ""})
url = (f"{base}/api/history/period/"
f"{urllib.parse.quote(s.isoformat())}?{q}")
try:
doc, attempts = _get(url, token)
except Exception as exc:
rec["files"][key] = {"read": "FAILED", "error": str(exc)}
print(f" {day} {key:9s} FAILED {exc}", flush=True)
rc = 1
continue
series = doc[0] if doc else []
body = b""
n_bad = 0
for row in series:
t = row.get("last_changed") or row.get("last_updated")
v = row.get("state")
if t is None or v is None:
continue
try:
float(v)
except (TypeError, ValueError):
n_bad += 1
body += (json.dumps({"t": t, "v": v}) + "\n").encode()
fn = f"{day}-{key}.jsonl.gz"
with gzip.GzipFile(out_dir / fn, "wb", mtime=0) as fh:
fh.write(body)
rec["files"][key] = {
"read": "OK", "entity": ent, "samples": len(series),
"non_numeric_states": n_bad, "attempts": attempts, "file": fn,
"bytes_raw": len(body),
"sha256_raw": hashlib.sha256(body).hexdigest()}
print(f" {day} {key:9s} {len(series):7d} rows, {n_bad} non-numeric",
flush=True)
man["days"].append(rec)
(out_dir / "manifest.json").write_text(json.dumps(man, indent=1) + "\n")
return rc
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--export", action="store_true")
ap.add_argument("--days", type=int, default=16)
ap.add_argument("--url", default=DEFAULT_URL)
ap.add_argument("--token", default=os.environ.get("HASS_TOKEN"))
ap.add_argument("--out-dir", default=str(SCENARIO))
ap.add_argument("--sensitivity", action="store_true")
args = ap.parse_args()
if args.export:
if not args.token:
raise SystemExit("--export needs --token or $HASS_TOKEN")
return export(args.url, args.token, args.days, args.out_dir)
from app.control import Tuning
t0 = time.time()
rows = stream()
span = (rows[-1][0] - rows[0][0]) / 86400.0
dts = sorted(rows[i + 1][0] - rows[i][0] for i in range(len(rows) - 1))
print(f"\nTHE RECORDING: {len(rows)} meter instants over {span:.2f} days, "
f"{SCENARIO.name}")
print(f" tick p5 {pct(dts, 5):.2f} s median {pct(dts, 50):.2f} s "
f"p95 {pct(dts, 95):.2f} s (control.LOOP_TICK_S says 2.0)")
# ⚠️ READ OFF THE RECORDING, NOT ASSUMED. `max_w` is the setpoint's own
# extreme and `slew_w` is the step the recorded command takes over and over.
sp = [r[3] for r in rows]
steps = {}
for i in range(1, len(rows)):
if rows[i][0] - rows[i - 1][0] < 8.0:
steps[round(abs(sp[i] - sp[i - 1]))] = \
steps.get(round(abs(sp[i] - sp[i - 1])), 0) + 1
top = max((c, w) for w, c in steps.items() if w > 100)
print(f" the recorded command: extremes {min(sp):+.0f} / {max(sp):+.0f} W"
f" -> max_w {LIVE['max_w']:.0f}; most common step above 100 W is "
f"{top[1]:.0f} W, {top[0]} times -> slew_w {LIVE['slew_w']:.0f}")
fit = fit_plant(rows, stride=7)
print(f" the fitted inverter: lag {fit[0]:.1f} s, tau {fit[1]:.1f} s, "
f"batt = {fit[2]:.4f} x cmd {fit[3]:+.1f} W, residual {fit[4]:.0f} W "
f"RMS above the SoC floor")
before, after = laws()
T = Tuning(**LIVE)
tr_b = replay(rows, before, fit, T)
tr_a = replay(rows, after, fit, T)
tab_b, tab_a = table(tr_b, T), table(tr_a, T)
print("\nFIDELITY - the BEFORE law replayed against the recording it came from")
fidelity(tr_b, rows)
print("\nTHE A/B, same ticks, same disturbance, same plant, same seed")
show(tab_b, tab_a)
if args.sensitivity:
print("\nSENSITIVITY - the ranking, not the watts")
variants = [
("plant lag 5.0 s", (5.0, fit[1], fit[2], fit[3], 0.0), LIVE),
("plant lag 10.0 s", (10.0, fit[1], fit[2], fit[3], 0.0), LIVE),
("plant tau 8 s", (fit[0], 8.0, fit[2], fit[3], 0.0), LIVE),
("gain 1.0135, offset -26", (fit[0], fit[1], 1.0135, -26.0, 0.0), LIVE),
("slew_w 555.6 (the rail)", fit, dict(LIVE, slew_w=555.6)),
("gain 0.3", fit, dict(LIVE, gain=0.3)),
]
print(f" {'variant':<26}{'export kWh':>22}{'worst quarter W':>26}"
f"{'mean quarter dW':>18}")
for name, f2, tune in variants:
T2 = Tuning(**tune)
b = table(replay(rows, before, f2, T2), T2)
a = table(replay(rows, after, f2, T2), T2)
d = [a["_q"][k] - b["_q"][k] for k in b["_q"] if k in a["_q"]]
print(f" {name:<26}{b['export_kwh']:9.1f} ->{a['export_kwh']:9.1f}"
f"{b['worst_quarter_w']:13.0f} ->{a['worst_quarter_w']:9.0f}"
f"{sum(d) / len(d):+18.2f}")
print(f"\n ({time.time() - t0:.0f} s)")
return 0
if __name__ == "__main__":
sys.exit(main())