Compare commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:37:01 +02:00
glenn schrooyenandClaude Opus 5 03ef61fa3b 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
2026-09-19 02:36:28 +02:00
74 changed files with 2594 additions and 53 deletions
@@ -187,6 +187,23 @@ over-absorption. So the rig does not choose the number — **the constraint
chooses it, and the rig says what the constraint does and does not buy.** That
is the same relationship the 2026-08-22 table had to its own three columns.
⚠️ **FORWARD REFERENCE, ADDED BY SAFETY-48 — THIS TABLE AND THIS SENTENCE ARE
TRUE OF release/1.0's LAW AND FALSE OF THE ONE THAT REPLACED IT.** Nothing above
is retracted: re-run on release/1.0 today the same rig prints the same column
(958 / 1107 / 1133 / 1058 / 987 / 1029 / 1150 / 1143 / 1081 W), flat,
non-monotone, 800 better than 556. Under SAFETY-48's lead cap the **same script
on the same plant** prints 777 / 734 / 663 / 563 / 553 / 507 / 435 / 358 / 321 W
— falling monotonically, so a slower slew is now *better* and the sentence above
inverts. The mechanism SAFETY-39 identified is exactly why: `slew_w` bounds the
OUTPUT while the integrator goes on accumulating `gain · error`, so slowing the
slew used to buy nothing; bound the integrator's lead and it starts to.
`tools/slew_sim.py --slew` now names which of the two laws it is running under
before it narrates the column, and
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` §6 carries both tables and
the independent confirmation from `test_control.py`'s own plant model. ⚠️ **This
does NOT re-open the choice of 556**: the constraint picks the number, the rig
does not, and that is unchanged in both laws.
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
and the phase of the step against the tick move the result by hundreds of watts,
non-monotonically. The first cut of this derivation was a single step and ranked
@@ -0,0 +1,551 @@
# SAFETY-48 — the slew step ceiling reaches the integrator, and what it bounds is the LEAD
**Status:** decided and implemented on branch `SAFETY-48`, on top of `TEL-10`.
**Card:** SAFETY-48 — *"the integrator goes on winding while the command is
slew-limited"*.
**Nothing here reached the inverter.** The live install still runs `slew_w: 400`
applied by hand on 2026-09-05 12:00 and `release/1.0`'s control law. This branch
does not deploy and does not bump the version.
---
## 0. The four things a reader should take away
1. **It is a missing case, not a wrong constant, and the arithmetic says so.**
The integrator moves `gain * error` a cycle and the command moves at most
`slew_w`, so the integrator outruns the command whenever
`|error| > slew_w / gain` — 667 W at the live shape. SAFETY-39's retune made
`slew_w` **smaller**, which makes that threshold **lower**. §1.
2. ⚠️ **THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, AND THAT IS WHY
IT IS NOT WHAT SHIPPED.** Reusing the asymmetric freeze on a carried slew
flag cuts export 13 % and moves the worst quarter-hour average **offtake**
from 3739 W to **3935 W**, with the mean quarter +2.7 W — and on the billed
quarter it is worse at **every plant lag where release/1.0 produces a peak
at all** (a tie at lag 0, worse at 2.5 / 5.0 / 7.5 / 10.0 s) and at **every
top-N cut of the quarters**, which makes this the least lag-sensitive result
in the card. The harm is a tail redistribution inside quarters that already
net to offtake, not a weaker answer to import in aggregate. §3, §3a.
3. **What ships bounds the LEAD at one slew step, only where the slew is what
stopped the command, and only in the leading direction.** The robust result
is the **export reduction — 39.6 → 34.1 kWh, and it falls at every plant and
tuning tried** — with wasted throughput 4.67 → 3.19 kWh, sign reversals
2992 → 2770 and median recovery 232 → 199 s. ⚠️ **The billed quarter is not
something this card can claim.** It reads 3739 → **3434 W** at the fitted
plant lag of 7.5 s; at every lag ≤ 5.0 s — including the one that best
reproduces the recorded export — release/1.0 is *already* at 3434 W and this
card moves it by exactly zero. The lag is not identified by the data. §4,
§3a.
4. **Two committed numbers moved and neither is a silent edit.** SAFETY-13's
released command settles lower, because there is less wound integrator to
settle onto; and SAFETY-39's *"the slew does not own the over-absorption"* is
now **false — inverted, on its own independent plant model.** §5, §6.
---
## 1. The defect, in the terms the code uses
`compute()` freezes the integrator with an asymmetric rule, and the comment
beside it states the requirement outright:
> the requirement is that the INTEGRATOR stop accumulating, not only the command
But `frozen` is computed from **one** signal:
```python
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w # not tracking
```
Slew-limiting never enters it. When the command hits the step ceiling the
integrator keeps winding at `gain * error` until it reaches `integrator_max_w`,
and the unwind afterwards is `integrator_max_w / slew_w` cycles — 12.5 at the
live shape, about a minute at the meter's measured tick.
⚠️ **AND THE RETUNE MADE IT MORE FREQUENT, NOT LESS.** `|error| > slew_w / gain`
is 1667 W at `slew_w: 1000` and **667 W** at `slew_w: 400`. The measured median
grid error at the onset of a recorded episode is 1114 W — above the second
threshold and below the first. This is why SAFETY-39 did not fix it, and the
mechanism is the same one §6 shows inverting.
## 2. How it was measured, and what the measurement cannot say
`tools/replay_recorded_loop.py`, over **16.00 days / 256 252 meter instants** of
the live install's own recorder, exported read-only.
**Real:** the disturbance (`D = grid + batt` at every recorded meter instant —
the house with this loop's own battery taken back out, which is exogenous and
therefore valid to drive a counterfactual with); the tick (the meter's own
recorded instants, median **5.01 s**); and the control law, imported out of the
checkout. The "before" law is the **same file** with the SAFETY-48 block removed
by a named substitution and exec'd into a fresh namespace — never a
transcription — and a missing or duplicated anchor is a **refusal** with
`NOTHING HAS BEEN MEASURED` on it, because an instrument that quietly compared
the shipped law with itself would report the card as changing nothing.
**Modelled:** the inverter (pure delay 7.5 s + first-order lag 2.0 s +
`batt = 0.973 × cmd − 22.4 W`, fitted on this same recording above the SoC
floor, residual **375 W RMS**); the pack (SoC integrated, discharge refused at
or below the floor); and **everything else the arbiter does** — the recorded
setpoint is the arbiter's output after the peak guard, the SoC clamps,
maintenance and arbitrage, while the replay runs `compute()` alone.
⚠️ **THE ABSOLUTE FIGURES ARE PESSIMISTIC BY ABOUT A THIRD AND THE TOOL PRINTS
IT ON EVERY RUN.** Replaying the before-law reproduces 39.6 kWh of export where
the install actually exported **29.4 kWh** over the same window. The delta is
what this instrument is for: both laws run on the same ticks, the same
disturbance, the same plant and the same seed.
⚠️ **THE SoC FILTER ON THE PLANT FIT IS THE WHOLE FIT, AND IT IS A FINDING IN
ITS OWN RIGHT.** This install spends **36 % of its ticks at or below a 10 % SoC
with the command standing near 2 kW and the battery delivering nothing.** Fitted
over all ticks the recording says `batt = 0.50 × cmd − 396 W, r = 0.70` — a
plant that follows half the command, which no inverter does. Above the floor the
same data says `batt = 1.00 × cmd − 28 W, r = 0.977`. The floor is the PACK and
is modelled separately.
## 3. The form the card asked for, and the number that refused it
The card asked for the asymmetric rule already in service for saturation, on a
flag carried across cycles the way `sat_count` is. It was built — from
release/1.0's own source, one substitution per change — and measured on the same
16 days through the same pipeline:
| | release/1.0 | **form A** — carried slew flag + the asymmetric freeze | **SAFETY-48** — the lead cap |
|---|---:|---:|---:|
| export kWh | 39.61 | 34.43 | **34.07** |
| import kWh | 116.07 | 110.62 | 111.20 |
| battery sign reversals | 2992 | **2656** | 2770 |
| wasted throughput kWh | 4.67 | **2.99** | 3.19 |
| **worst 15-min offtake W** | 3739 | ⚠️ **3935** | **3434** |
| mean quarter-hour offtake delta | — | ⚠️ **+2.73 W** | **−2.39 W** |
| worst quarter-hour delta | — | +992 W | +532 W |
| recovery median s | 232 | **184** | 199 |
| recovery p99 s | 849 | **781** | 850 |
| recovery max s | 901 | 890 | 898 |
| unrecovered (> 900 s) | 53 | **26** | 31 |
| \|i_w − prev_w\| p99 W | 3361 | 1534 | **800** |
| \|i_w − prev_w\| max W | 9200 | 6912 | **800** |
**Form A wins on four rows and loses on the one that is billed.** The capacity
tariff (*capaciteitstarief*) bills the month's worst quarter-hour average
offtake, and form A raises it by **196 W** — and raises the MEAN quarter by
2.7 W, so this is systematic and not one unlucky quarter. It does so in **five
of the six** plant and tuning variants in `--sensitivity`; the sixth is a tie.
The per-cycle mechanism is that a frozen integrator stops at
`prev + gain·error(first cycle)`, which is `(1 − gain)` short of what the site
needs — 40 % short at `gain: 0.6`.
⚠️ **AND THE SENTENCE THAT USED TO FOLLOW THAT ONE WAS TOO BIG FOR THE DATA.**
It read *"for an IMPORT it is offtake, so a large import is answered too weakly
for as long as the ramp lasts"*. The aggregate does not show a weaker answer to
import: form A cuts total import by **5.45 kWh** and export by **5.18 kWh**
against release/1.0, and paired tick by tick it delivers **8.56 kWh LESS**
offtake energy on the ticks release/1.0 spent importing (lower on 38 229 such
ticks, higher on 19 669). What form A moves is the **TAIL, within quarters that
already net to offtake** — the worst quarter goes 3739 → 3935 W, the top five
3260 → 3300 W, the top fifty 1880.0 → 1882.7 W. The capacity tariff bills the
worst quarter and nothing else, so a redistribution that leaves the mean alone
and lifts the maximum is exactly the trade that is refused here. The conclusion
is unchanged; the causal sentence is now the one the data supports.
⚠️ **THE CARD SAID "do not invent a second rule", AND THE MEASUREMENT SAYS TO.**
That instruction is recorded here rather than quietly dropped. What ships is a
different rule; the paragraph above is why.
## 3a. ⚠️ The plant lag is NOT identified by this recording, and the billed quarter is the claim that depends on it
`fit_plant()` picks `lag 7.5 s` because it minimises **battery-power RMS**. That
objective barely distinguishes the candidates — 375.3 W at 7.5 s against 376.4 W
at 2.5 s, **0.3 %** — while the tool's *own* printed fidelity measure, replayed
export against the **29.43 kWh this install actually exported**, prefers 2.5 s by
the whole of the 35 % gap the header warns about. Two measures on the same data
disagree, and nothing here breaks the tie.
So the sweep has to be read as a sensitivity, not as a confirmation:
| plant lag | fit RMS W | replayed export kWh (release/1.0) | **worst 15-min offtake W:** release/1.0 | form A | SAFETY-48 | mean quarter ΔW |
|---:|---:|---:|---:|---:|---:|---:|
| 0.0 | 392.3 | 23.5 | **3434** | 3434 | **3434** | +0.53 |
| 2.5 | **376.4** | **28.9** ← nearest 29.43 | **3434** | ⚠️ 3732 | **3434** | −0.59 |
| 5.0 | 378.0 | 32.1 | **3434** | ⚠️ 3711 | **3434** | −1.02 |
| 7.5 | **375.3** ← chosen | 39.6 | 3739 | ⚠️ 3935 | **3434** | −2.39 |
| 10.0 | 384.2 | 75.4 | 3954 | ⚠️ 4326 | **3434** | −4.65 |
(The RMS column is `fit_plant()`'s own inner sweep pinned to one lag — best `tau`
and a re-fitted gain/offset at that lag. The export and quarter columns follow
`--sensitivity`'s convention instead: only the lag is substituted into the
global fit, everything else held. That is why the lag 5.0 and 10.0 export
figures are the ones already printed in §7b.)
⚠️ **AT EVERY LAG ≤ 5.0 s THE IMPROVEMENT IS NOT AN IMPROVEMENT — IT IS AN
ABSENCE.** release/1.0's worst quarter is *already* 3434 W there and SAFETY-48
changes it by **exactly zero**. §7b's paragraph used to read the `3434 → 3434`
rows as "the direction does not move"; that is the wrong reading. The direction
has nothing to move: at those plants release/1.0 never produces a quarter worse
than 3434 W in the first place, and the 3739 → 3434 W headline exists only at
the single longest lag `fit_plant()` happens to select. **The peak claim is a
property of the plant model, not of the card.** What the card can claim on the
billed quantity is that it does not make it worse, at any lag.
⚠️ **AND THE ONE RESULT HERE THAT IS NOT LAG-SENSITIVE IS THE FORM-A
REJECTION.** Form A's worst quarter is the worst of the three at **every lag
where release/1.0 produces a peak at all** — 3732, 3711, 3935, 4326 against
release/1.0's 3434, 3434, 3739, 3954 at lags 2.5 / 5.0 / 7.5 / 10.0, and a tie
at lag 0.0 where both sit at 3434. It is worse at every top-N cut of the
quarters at the fitted plant (top-1 3934.6 against 3738.7, top-5 3299.7 against
3260.5, top-10 2890.6 against 2871.0, top-50 1882.7 against 1880.0, top-100
1481.7 against 1479.0), and it raises the mean quarter. §3 understated this: the rejection does not rest on one unlucky
quarter at one plant, which is exactly the objection a lag-sensitive result
invites.
⚠️ **WHAT WOULD SETTLE IT IS NOT IN THIS RECORDING.** Both objectives above are
computed from the same four recorded entities, and they disagree; nothing else
in the capture breaks the tie, and this card did not go looking for a third
objective after seeing which lag each of the first two preferred. A driven step
response — the command timestamped where it is written rather than where Home
Assistant recorded it — is what identifies a lag. That is a rig measurement and
it is not this card. Until it exists, the export reduction is what this card
delivers and the peak figure is a plant-model artefact.
## 4. What ships
```python
slew = slew_limit_w(tuning)
slewed = max(prev_w - slew, min(prev_w + slew, target))
if slewed != target:
reason = "slew-limited"
_lead_cap = SLEW_LEAD_STEPS * slew
if target > slewed:
i_w = min(i_w, slewed + _lead_cap)
elif target < slewed:
i_w = max(i_w, slewed - _lead_cap)
target = slewed
```
Four properties, and each of them is a decision:
* **It is inside the branch**, and ⚠️ **that placement was a SURVIVOR of all
thirty files until a check was written for it.** Applying it to every cycle
instead — `i_w = max(target - cap, min(target + cap, i_w))` after
`target = slewed`, −92 B — is **bit-identical on the shipped configuration**:
0 of 256 246 ticks differ over the 16-day recording. The two separate **only**
where `integrator_max_w` is set ABOVE `max_w` — the configuration `control.py`
says is the only way its `clamped` reason is reachable. There the integrator
is *deliberately* allowed above the rail, and an unconditional cap would drag
it back to the command every cycle and make the option inert.
`test_telemetry.py` part 4 drives that configuration, and the unconditional
form is red on it.
⚠️ **THE COUNT OF SEPARABLE STATES IS DECORATION AND THREE SWEEPS DISAGREE
ABOUT IT.** This card's grid gave **2050 of 257 400**; an independent review
measured **1220 of 246 064**; a third, smaller grid (four tunings × 7 `prev_w`
× 9 `grid_w` × 5 `actual_w` × 8 `i_w` including `None` × 4 `sat_count`) gives
**776 of 40 320** — and that third one is spelled out here precisely because
the first two are not, which is the whole reason they cannot be reconciled.
No count here should be read as exact without the grid beside it. All three
agree on the load-bearing part, which is **where**: every separable state sits
in the `integrator_max_w > max_w` tuning and nowhere else.
⚠️ **AND AN EARLIER DRAFT OF THIS BULLET GAVE THE WRONG REASON AND THE WRONG
NUMBER.** It said the two forms differ on 118 ticks because an unconditional
cap would pull the integrator toward a command the SATURATION freeze is
pinning. That 118 was measured against a *different* pair — a band applied
after quantisation, at the very end of `compute()` — and the saturation story
does not apply to the placement actually being compared, which sits before
the freeze. The figures above are the ones that were measured on the pair the
sentence names.
⚠️ **AND THE SATURATION STORY IS NOT MERELY INAPPLICABLE — IT IS BACKWARDS.
THE NARROW CAP THAT SHIPPED *DOES* MOVE A SATURATION-FROZEN INTEGRATOR.** It
sits before the freeze block and is not gated on `frozen`. Measured on the
same 16 days at the live shape: of **79 160** frozen ticks the cap changed
`i_w` on **1095**, and on **679** of those it moved it further out on the side
it was already on — putting back magnitude the freeze had just let fall, which
is the one direction the freeze's rule ("may fall, never rise") exists to
forbid. Worst case **2424 W**, at
`prev_w −3400 W, grid +4697 W, actual −4144 W, sat_count 2 → 3`: the freeze
leaves the integrator at **−176 W**, the slew binds, and the cap drags it to
**−2600 W**. It never overrides a *same-cycle* refusal — the freeze clipped
`moved` on 75 512 ticks and the slew bound on none of them — but the claim
that this placement leaves the frozen path alone is false, and §5 is where
that shows up in a committed number.
* **It is written one-sided, and that is a CLARITY choice, not a correctness
one.** ⚠️ **An earlier draft of this file and of the comment in `control.py`
said the two-sided band `max(slewed - cap, min(slewed + cap, i_w))` was a bug
that would drag a TRAILING integrator up toward the command. That was
invented, and the sweep said so: the two spellings agree on 205 920 of
205 920 cases** across four tunings, every sign of `prev_w`, `grid_w`,
`actual_w` and `i_w`, and the `i_w=None` path. `target` is monotone in `i_w`,
so when the slew binds `i_w` lies on the same side of `slewed` as `target`
does and the other clamp cannot reach. The one-sided form stays because it
NAMES the side the lead is on; the equivalence is recorded so the next reader
finds a measurement instead of an argument.
* **It moves `i_w` and not `target`.** This cycle's command is already decided;
what is bounded is what gets CARRIED. That is also why the exhaustive
`i_w=None` equivalence sweep in `test_control.py` is untouched — it compares
`(target_w, sat_count, frozen, reason)`, none of which this moves.
* **The cap is in STEPS, not watts**, for SAFETY-39's reason: a watt figure is a
constant that outlives the `slew_w` it was derived against. `SLEW_LEAD_STEPS`
moves with `slew_limit_w()` and therefore with the rail.
⚠️ **ONE STEP, AND NOT ZERO.** `i_w = slewed` is full back-calculation, and
`control.py`'s own docstring says what that costs: passing `i_w=None` *"reduces
this function to the exact velocity form it replaced"*. A zero cap makes `i_w`
identically the command on every cycle, which is that collapse written from the
other end. Measured, it is **better** — export 32.02 kWh against 34.07, mean
quarter −3.69 W against −2.39 — and it is still refused, because it throws away
the reason the accumulator was split out at all. That 2 kWh is the price of
keeping the bounded form, and it is stated rather than hidden.
⚠️ **TWO LEADS, AND CONFUSING THEM IS HOW A CHECK GETS WRITTEN WRONG.** The cap
bounds `i_w − target`, the command this cycle EMITS, at one step — which is what
makes the unwind one cycle. `Decision.i_w − prev_w`, which is what TEL-10's
`loop_lead` publishes, is measured against the command the PREVIOUS cycle left
standing, and the emitted command is itself one step further on: the published
figure is bounded by **two** steps. Both bounds are asserted, each against its
own number.
### The two recorded deadlocks, driven rather than argued
`control.py` names two encodings that produce a permanently stuck loop, and a
gate written on the integrator is exactly the edit that brings them back.
Neither is reachable here and both are test cases in `test_telemetry.py` part 4:
* **the shrink-only encoding** — *"only corrections that shrink |i_w|"* is
unsatisfiable for both signs whenever the correction is larger than twice the
integrator, and the loop then sits at its last value forever (measured before
the fix: 0 W held indefinitely into a 2 kW import). SAFETY-48 is not a shrink
rule; it caps the lead beyond one slew step and only while the slew binds.
* **`i_w == 0.0`** — `main.py` resets the integrator to exactly 0.0 on every
stop and every reseed, and *"may not wind further in the direction it is
already pushing"* has no referent there. SAFETY-48 does not touch that gate;
the first push from zero is driven and asserted to move.
And a third, specific to this card: **the cap cannot latch.** The flag is not
carried, and after the cap `|i_w − slewed| = cap`, so the next cycle's command
covers it in one step and the slew stops binding — after which the integrator
accumulates freely again. The driven check asserts the command still walks all
the way to the rail on a railed export.
## 5. What this card COSTS, stated rather than smoothed
**SAFETY-13's release buys back less.** That card's own scenario — a second
writer holding the pack at +700 W, the command pinned, then released — settles
at **−800 W** where release/1.0 settled at −1190 W against an integrator of
−1194 W; and its sibling run settles at **−1200 W** where release/1.0 settled at
−2390 W. The property AC 4 asserts is unchanged: the released command stops at
the frozen integrator and not at the rail.
⚠️ **AND THE TWO MOVED FOR TWO DIFFERENT REASONS, WHICH THE ONE-LINE EXPLANATION
"how far that integrator had been allowed to wind" COVERS ONLY HALF OF.** Gating
the cap on `not frozen` — one line, `if frozen: _lead_cap = float("inf")`, so
the cap becomes a no-op while frozen — puts **part 5 back at release/1.0's
−1190 W exactly**. That run moved because the cap **fires on frozen cycles**
(§4). **Part 4(b) does not come back**: it is −1200 W gated and −1200 W shipped,
against release/1.0's −2390 W, because *that* one moved for the winding reason.
Both literals in `test_control.py` were re-baselined **with the reason beside
them**, and the second is now asserted against `i_w` rather than against a
number.
**The alternative that avoids it was measured and rejected.** Gating the cap on
`not frozen` as above gives export **34.71 kWh** (against 34.07), worst quarter
**3434 W** (the same as shipped), wasted **3.49 kWh** (against 3.19) — and a
worst observed lead of **4961 W**, i.e. no bound at all on the quantity this
card exists to bound. A bound with an exception large enough to drive a truck
through is not a bound. ⚠️ These four figures replace an earlier set (34.9,
3461 W, 3.5 kWh, 4966 W) that does not reproduce on this tree with the gate
spelled as above; the gate spelling is written out here so the next reader can
re-run it rather than re-derive it.
**One quarter-hour in 1534 is 532 W worse.** The billed figure is a MAXIMUM, and
at the fitted plant lag the maximum improves (3739 → 3434 W); no quarter under
SAFETY-48 reaches release/1.0's own worst. The mean moves the right way. The
tail is reported because a maximum over 1534 quarters is decided by one of them
— and §3a is the reason that improvement is not claimed as a benefit of the
card: at every shorter plant lag release/1.0's own maximum is already 3434 W.
## 6. The independent confirmation, which arrived as a failing check
`test_control.py`'s SAFETY-39 section drives a family of 51 synthetic export
steps through a **first-order plant this file writes itself** — no relation to
the replay, no recorded data in it at all. It carried this assertion:
> ⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION … Quartering the slew from 1000
> to 250 does not reduce the over-absorption at all
and it was right about the mechanism and therefore wrong about the conclusion:
`slew_w` bounds the OUTPUT while the integrator accumulates `gain · error`, so a
slower slew left the integrator winding for longer. Worst case over the family:
| `slew_w` | release/1.0 | this branch |
|---|---:|---:|
| 1000 | 1005 W | **729 W** |
| 0 (the rail, 556) | 1109 W | **550 W** |
| 400 (the live interim) | 1285 W | **424 W** |
| 250 | 1330 W | **326 W** |
**The ranking inverts.** Before: slower is worse, monotonically. After: slower is
better, monotonically. Two plant models that share nothing agree on the sign,
which is worth more than either alone — and it means SAFETY-39's *"gain is the
next constant to re-derive"* is now only half true: with the lead bounded, the
slew owns a share of the over-absorption it did not own before. `gain` is still
not touched here, for SAFETY-39's own reason: two live control-law constants
moving in one release makes the next measurement uninterpretable.
⚠️ **AND THE RIG SAYS THE SAME THING, WHICH MEANS TWO COMMITTED TRANSCRIPTS
CHANGED MEANING UNDER THIS CARD.** `tools/slew_sim.py --slew` runs the same
derivation on admin/goodwe's `plantsim.Plant`, a third model again. Worst-case
over-absorption over its 51-step family, `slew_w` from 1000 down to 250:
| `slew_w` | 1000 | 800 | 700 | 600 | 556 | 500 | 400 | 300 | 250 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| release/1.0 | 958 | 1107 | 1133 | 1058 | 987 | 1029 | 1150 | 1143 | 1081 |
| **this branch** | 777 | 734 | 663 | 563 | 553 | 507 | 435 | 358 | 321 |
release/1.0's row is flat and non-monotone and **800 really is better than 556**
on that column — SAFETY-39 measured it correctly and its §3 sentence is true of
the law it was written against. This branch's row falls monotonically. So
SAFETY-39's record is not retracted; it is given a **forward reference**, and
`slew_sim.py`'s own narration now reads `SLEW_LEAD_STEPS` out of the control law
it imported and says which of the two tables it is printing. MUTATION.md habit 8
— a committed transcript is a check, and a transcript whose prose contradicts
its own table is a failing one.
## 7. Verified by breaking it
**The law.** `test_telemetry.py` part 4 and `test_control.py`'s SAFETY-39
section. The mutation table is in `test_telemetry.py`'s header.
**The instrument** (MUTATION.md habit 8 — anything under `tools/` that a
document quotes is subject to the battery):
| # | mutation to `app/control.py` | what the instrument did |
|---|---|---|
| I2 | the whole SAFETY-48 block deleted | **REFUSED**, exit 1, `NOTHING HAS BEEN MEASURED` — not a zero delta |
| I3 | the anchor line itself edited | **REFUSED**, same message |
| I3b | `SLEW_LEAD_STEPS = 1000` — the cap present but inert | **every row +0.00**, which is the honest report that the block is doing nothing |
| I4 | `SLEW_LEAD_STEPS = 0` — full back-calculation | every row moves further; this is the rejected form's number in §4 |
I3b is the one that matters: it proves the delta in §3 is caused by the block
and not by the two runs differing for some other reason.
**The law**, driven in the CI image on a purged tree, fingerprinted before,
after and after the revert:
| # | mutation to `app/control.py` | bytes | reds |
|---|---|---:|---|
| L1 | the whole SAFETY-48 block deleted | −195 B | 5 in `test_telemetry.py`, 4 in `test_control.py` |
| L2 | `SLEW_LEAD_STEPS = 0` — the velocity form | +0 B | 4 + 3 |
| L3 | `SLEW_LEAD_STEPS = 12` — a cap above the rail | +1 B | 4 + 3 |
| L4 | the cap applied unconditionally, outside the slew branch | −92 B | 1 + 0 — **and it was a SURVIVOR of all thirty files before that check existed** |
| L5a | the `min`/`max` FUNCTION names exchanged | +0 B | 5 + 5, one of them `test_control.py`'s own "frozen integrator never deadlocks" over 13 041 states |
| L5b | the two BRANCH BODIES exchanged — the other reading of "the cap on the wrong side" | +0 B | 5 + 4, and it does **not** reach the deadlock sweep |
| L6 | the lead measured against `prev_w` rather than the command emitted | +0 B | 6 + 3 |
⚠️ **L5 AND L6 ARE BOTH ZERO BYTES** — `min`/`max` and `slewed`/`prev_w` are
each the same length — so no fingerprint and no byte delta can see either, and
one of L5a's reds is `test_control.py`'s own *"frozen integrator never
deadlocks"* sweep over 13 041 states, written by SAFETY-13 for a different
reason.
⚠️ **THREE OF THESE COUNTS WERE WRONG AND ARE RE-MEASURED ABOVE, WHICH IS
MUTATION.md's REPORTING RULE 3 APPLIED TO THIS CARD'S OWN TABLE.** The earlier
row gave L5 as 2 + 5 and L6 as 4 + 3; driven again in the CI image on a `cp -a`
of this tree, purged, `:ro`, one file at a time, they are **5 + 5** and
**6 + 3**. ⚠️ **AND "L5" NAMED TWO DIFFERENT MUTATIONS.** "`min`/`max` swapped"
can mean the function names exchanged or the branch bodies exchanged; they are
both red, they are both the cap on the wrong side, and they give different
counts (5 + 5 against 5 + 4) and reach different checks. A row that writes one
number for a mutation a reader cannot re-derive from its own description is a
row that cannot be re-run, so both spellings are listed. The directions in the
original table were all correct; the counts were decoration, and decoration in
a mutation table reads as evidence.
## 7b. The transcript, quoted rather than summarised
`python3 tools/replay_recorded_loop.py --sensitivity`, on this branch, 58 s:
```
THE RECORDING: 256252 meter instants over 16.00 days, loop-2026-09-02_2026-09-17
tick p5 4.77 s median 5.01 s p95 7.86 s (control.LOOP_TICK_S says 2.0)
the recorded command: extremes -5000 / +5000 W -> max_w 5000; most common step above 100 W is 400 W, 9924 times -> slew_w 400
the fitted inverter: lag 7.5 s, tau 2.0 s, batt = 0.9731 x cmd -22.4 W, residual 375 W RMS above the SoC floor
FIDELITY - the BEFORE law replayed against the recording it came from
setpoint r=0.887 mean +601 W simulated vs +739 W recorded
grid r=0.682 mean +195 W simulated vs +239 W recorded
export 39.61 kWh simulated vs 29.43 kWh recorded -> the model is +35 % PESSIMISTIC about the loop's tracking
THE A/B, same ticks, same disturbance, same plant, same seed
release/1.0 SAFETY-48 delta
export kWh 39.61 34.07 -5.54 the cost
import kWh 116.07 111.20 -4.86
battery sign reversals 2992.00 2770.00 -222.00
wasted throughput kWh 4.67 3.19 -1.48
worst 15-min offtake W 3738.67 3433.94 -304.74 ⚠️ THE BILLED ONE
recovery events 549.00 612.00 +63.00
recovery median s 232.06 199.04 -33.01
recovery p99 s 849.00 849.94 +0.94
recovery max s 901.06 898.40 -2.66
unrecovered (>900 s) 53.00 31.00 -22.00
|i_w - prev_w| p99 W 3361.12 800.00 -2561.12
|i_w - prev_w| max W 9200.00 800.00 -8400.00
slew-limited ticks 16392.00 11654.00 -4738.00
mean quarter delta W -2.39 over 1534 quarters
worst quarter delta W +532.27
SENSITIVITY - the ranking, not the watts
variant export kWh worst quarter W mean quarter dW
plant lag 5.0 s 32.1 -> 27.2 3434 -> 3434 -1.02
plant lag 10.0 s 75.4 -> 68.3 3954 -> 3434 -4.65
plant tau 8 s 56.5 -> 49.8 3995 -> 3605 -3.30
gain 1.0135, offset -26 46.8 -> 39.3 3713 -> 3434 -3.27
slew_w 555.6 (the rail) 36.5 -> 34.4 3434 -> 3434 -0.17
gain 0.3 28.1 -> 27.0 3434 -> 3434 +0.16
(58 s)
```
⚠️ **`--sensitivity` IS THE RANKING AND NOT THE WATTS.** The export figure moves
by a factor of two across these plants; what does not move is the direction —
**on export**. ⚠️ **THE `worst quarter W` COLUMN IS NOT THE SAME KIND OF
EVIDENCE AND THIS PARAGRAPH USED TO TREAT IT AS IF IT WERE.** Three of its six
rows read `3434 -> 3434`, and that is not "the direction holds": it is
release/1.0 *already sitting at 3434 W* on those plants, with nothing for the
card to improve. §3a sweeps the lag properly and finds the same thing at every
lag ≤ 5.0 s. The billed-quarter improvement in the A/B table above exists at one
plant, the one `fit_plant()` selects by an objective that separates it from its
neighbour by 0.3 %.
Two rows deserve naming rather than rounding: at `slew_w: 555.6` (the rail) and
at `gain: 0.3` the mean quarter-hour delta is −0.17 W and **+0.16 W** — ties,
not improvements. Both are regimes where `|error| > slew_w / gain` is rarely
satisfied (1333 W at `gain: 0.3`, 926 W at the rail against a measured median
onset error of 1114 W), so there is little for the card to bite on. That is the
mechanism agreeing with itself, and it is also the honest answer to "would this
still be worth shipping if the tuning moved": at `gain: 0.3` it would not be.
## 8. What this card does NOT establish
* **Nothing here drives an inverter.** The replay is a model with a 375 W
residual and no arbiter in it. The closed-loop answer comes from the rig or
from a live run — SAFETY-47 records why a shadow run cannot give it.
* **The live tick is 5.0 s, not `LOOP_TICK_S`'s 2.0 s**, measured off 9924
recorded 400 W steps and off the meter's own cadence. It is wrong in the SAFE
direction (a slower meter makes `validate.py` rule 10 conservative) and is NOT
changed here — re-deriving `SLEW_RAIL_FRAC` is a refusal boundary on a live
install and belongs to its own card. It is recorded in the tool's header and
printed on every run.
* **The brief's plant figure was close but not exact.** It quoted
`delivered = 1.0135 × command − 26 W, r = 0.992` at a 4–6 s publish lag. On
this 16-day window, above the SoC floor, the same relation measures
`1.00 × command − 28 W, r = 0.977` at a best-fit pure delay of **7.5 s**. The
conclusion — the inverter is a pure follower and the plant is not the problem
— survives; the coefficients are the ones in §2.
* **The brief's cost figure is larger than this measurement.** It quoted ~14 kWh
of avoidable export and 65 % of site export inside 7.9 % of the time. Measured
here: **5.5 kWh** avoidable of 39.6 kWh modelled (29.4 kWh recorded), with
42 % of export inside the 6.3 % of ticks that are slew-limited and 69 % within
±60 s of one, over 15.3 % of the time. The concentration claim holds; the
avoidable-kWh figure does not, and the difference is not reconcilable from
here because the reconstruction it came from was discarded.
+37
View File
@@ -371,6 +371,43 @@ 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.
⚠️ **What the bound buys — and how much of it is a model rather than a meter
reading.** Sixteen days of the reference install's own recorded disturbances
were replayed through both laws (`tools/replay_recorded_loop.py`). The result
that survives every plant and tuning tried is the **export reduction: about an
eighth of the modelled export** — 5.5 kWh of 39.6 at the plant lag the replay
fits, 3.8 kWh of 28.9 at the shorter lag that best reproduces what this install
actually exported — with wasted battery throughput falling alongside it, 4.67 to
3.19 kWh at the fitted plant. ⚠️ **Those are simulated watt-hours.** The
replay's fitted inverter carries a ~375 W RMS residual and prints `+35 %
PESSIMISTIC` beside its own export figure on every run; the *direction* is what
it establishes, not the absolute kWh.
⚠️ **And it does NOT promise you a lower capacity-tariff peak.** The worst
quarter-hour average offtake — the billed quantity — improves from 3739 W to
3434 W **only at the longest plant lag the replay fits**. At every shorter lag,
including the one that best reproduces the recorded export, release/1.0 already
sits at 3434 W and this bound changes it by exactly zero. The plant lag is not
identified by the recording, so treat the peak as unchanged and the export as
the reason this exists. `DECISIONS/SAFETY-48-bounding-the-integrator-lead.md`
§3a has the table.
⚠️ **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
+151
View File
@@ -191,6 +191,80 @@ 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 16.0 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 3739 -> 3935 W and the mean quarter
# moves +2.7 W, in five of six plant and tuning variants, and on the
# billed quarter it is worse at every plant lag where release/1.0
# produces a peak at all (a tie at lag 0, worse at 2.5/5/7.5/10 s)
# and at every top-N cut of the quarters.
#
# ⚠️ AND THE MECHANISM IS A REDISTRIBUTION, NOT A WEAKER ANSWER. An
# earlier draft of this line said a frozen integrator undershoots by
# (1 - gain) "so a large import is answered too weakly". The aggregate
# does not show that: form A cuts total import by 5.45 kWh, and paired
# tick by tick against release/1.0 it delivers 8.56 kWh LESS offtake
# energy on the ticks release/1.0 was importing. What it moves is the
# TAIL - within quarters that already net to offtake, the worst of them
# gets worse - and the capacity tariff bills the worst quarter and
# nothing else. That is the wrong trade here and the reason this is not
# the fix that ships.
#
# (b) cap how far the integrator may LEAD the command, one slew step. Export
# 39.6 -> 34.1 kWh, wasted throughput 4.7 -> 3.2 kWh, sign reversals
# 2992 -> 2770, worst quarter-hour offtake 3739 -> 3434 W at the fitted
# plant lag (⚠️ AND AT NO SHORTER ONE: at every lag <= 5.0 s release/1.0
# is already at 3434 W and this changes it by zero - DECISIONS/SAFETY-48
# §3a), median recovery 232 -> 199 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.0 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 5.0 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 +581,83 @@ 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 - AND THE REASON IS NOT THE
# SATURATION FREEZE. An earlier draft of this comment said that applied
# unconditionally the cap "would also pull the integrator toward a
# command the SATURATION freeze is pinning", and priced that at 118 of
# 256 250 ticks. Both halves were invented, and MUTATION.md habit 22 is
# what fell out of it. The 118 had been measured against a DIFFERENT
# pair - a band applied after quantisation, at the very end of
# `compute()`. On the pair the sentence actually names - this same cap
# moved out of this branch to just after `target = slewed` - the two
# forms are BIT-IDENTICAL on the shipped configuration: 0 of 256 246
# ticks differ over the 16-day recording. They separate only where
# `integrator_max_w` is deliberately set ABOVE `max_w`, the one
# configuration this file says can reach its `clamped` reason; there
# the integrator is meant to run above the rail and an unconditional
# cap would drag it back to the command every cycle and make the option
# inert. THAT is why the cap stays in the branch, and
# `test_telemetry.py` part 4 drives it. (The separable-state COUNT is a
# property of whatever grid was swept - 2050 of 257 400 on this card's,
# 1220 of 246 064 on the review's, 776 of 40 320 on a third - so the
# claim to carry forward is WHERE they separate, not how many.)
#
# ⚠️ AND THE HALF A READER WILL OTHERWISE ASSUME AWAY: THIS CAP DOES
# MOVE A SATURATION-FROZEN INTEGRATOR. It runs BEFORE the freeze block
# below and is not gated on `frozen`. Measured over the same recording
# at the live shape: of 79 160 frozen ticks the cap changed `i_w` on
# 1095 of them, and on 679 of those it moved it FURTHER OUT on the side
# it was already on - putting back magnitude the freeze had just let
# fall, which is the one direction the freeze's own rule ("may fall,
# never rise") exists to forbid. Worst case 2424 W, and the worked tick
# is `prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3`:
# the freeze leaves the integrator at -176 W, the slew binds, and the
# cap drags it to -2600 W. It never overrides a SAME-CYCLE refusal -
# the freeze clipped `moved` on 75 512 ticks and the slew bound on none
# of them - but it does not leave the frozen path alone either.
#
# ⚠️ AND THAT IS WHERE SAFETY-13's SETTLE VALUES WENT. Gating this cap
# on `not frozen` puts `test_control.py` part 5 back at release/1.0's
# -1190 W exactly; part 4(b)'s -2390 -> -1200 W does NOT come back,
# because that one moved for the other reason - the winding BEFORE the
# freeze is bounded now. The gate is refused anyway: measured the same
# way it leaves a worst observed lead of 4961 W, i.e. no bound at all
# on the quantity this card exists to bound. DECISIONS/SAFETY-48 §5
# carries the rest of the price.
#
# ⚠️ 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",
+233 -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
@@ -46,6 +51,13 @@ of the tree, one file at a time, and say which tree. M7's count was written as
was decoration, which is the half of MUTATION.md's reporting rule 3 that is
easiest to let rot.
⚠️ AND SAFETY-48 HAS ALREADY MOVED ONE OF THEM. M1 defeats FIVE checks on
TEL-10's own tree and FOUR on this one: this card replaced the "...the gap is
the slew limit and not an accident" check, because the gap it asserted is now
bounded and the assertion is written against the bound instead. The mutation
did not change; the suite did. Both counts are right, for different trees, and
neither is right without one.
⚠️ M9 IS ZERO BYTES AND CHANGES NO PAYLOAD. Every id, name, unit and topic is
identical; only the ORDER of the retained messages moves. No fingerprint and no
byte delta can see it, which is why part 1 asserts the announcement as an
@@ -71,6 +83,41 @@ 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 5 here + 5 control
side (or 5 + 4; see below)
L6 the lead measured against `prev_w` rather than +0 B 6 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 only where
`integrator_max_w` is above `max_w`, which is the stimulus part 4 drives; the
COUNT of separable states is a property of whatever grid was swept and three
sweeps disagree about it, so DECISIONS/SAFETY-48 §4 carries all three rather
than one.
⚠️ AND L5 IS TWO MUTATIONS WEARING ONE NAME, WHICH IS WHY ITS COUNT NEEDS A
SPELLING BESIDE IT. "min/max swapped" can mean the FUNCTION names exchanged
(`max(i_w, slewed + cap)` / `min(i_w, slewed - cap)`) - 5 here + 5 in
test_control.py, one of the five being SAFETY-13's own "frozen integrator never
deadlocks" sweep over 13 041 states - or the whole BRANCH bodies exchanged
(`max(i_w, slewed - cap)` / `min(i_w, slewed + cap)`), which is 5 + 4 and does
NOT reach the deadlock sweep. Both are red, both are the cap on the wrong side,
and a table that writes one number for the pair is reporting a mutation nobody
can re-run. The first spelling is the one the row above quotes.
⚠️ 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 +130,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 +250,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 +277,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 +324,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 +376,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 +424,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())
+44 -4
View File
@@ -220,6 +220,7 @@ def slew_table(ps, ct, tick_s, rail):
f"faster than the")
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
overs = []
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
tick_s=tick_s)
@@ -227,13 +228,52 @@ def slew_table(ps, ct, tick_s, rail):
" <- the 2026-09-05 live pair" if slew == 1000 else (
" <- the live interim" if slew == 400 else ""))
row(f"slew_w {slew}{tag}", w)
overs.append(w["over"])
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
print(" every run below 900 W of slew, and that is what the slew owns.")
print(" The OVER-ABSORPTION does not move, and at the slowest slews it is")
print(" slightly WORSE. The rig cannot pick the derived value out of that")
print(" column, and it is not asked to: the CONSTRAINT picks it, and this")
print(" table is what says what the constraint does and does not buy.")
# ⚠️ THE NARRATION BELOW IS DERIVED FROM THE COLUMN JUST PRINTED, NOT
# WRITTEN ABOUT IT. SAFETY-39 wrote "the over-absorption does not move, and
# at the slowest slews it is slightly WORSE" - true of release/1.0's law,
# which is what it was measured against, and FALSE under SAFETY-48's lead
# cap, where the same script on the same plant prints a monotonically
# FALLING column. A transcript whose prose contradicts its own table is a
# failing check (MUTATION.md habit 8), and a sentence that is right under
# one law and wrong under another has to be computed rather than typed. So:
# which law is imported is read off `control.py` itself, and whether the
# column falls is read off the column.
cap = getattr(ct, "SLEW_LEAD_STEPS", None)
falling = all(b < a for a, b in zip(overs, overs[1:]))
law = ("release/1.0's law - the integrator's lead over the command is "
"UNBOUNDED" if cap is None else
f"SAFETY-48's law - the integrator may lead the command by "
f"{cap} slew step(s)")
print(f"\n THE LAW THIS RAN AGAINST: {law}.")
if cap is None and not falling:
print(" The OVER-ABSORPTION does not move with the slew, and at the")
print(" slowest slews it is slightly WORSE - `slew_w` bounds the")
print(" OUTPUT while the integrator goes on winding, so a slower slew")
print(" leaves it winding for longer. SAFETY-48 bounds that lead and")
print(" INVERTS this column; see DECISIONS/SAFETY-48 §6.")
elif cap is not None and falling:
print(" The OVER-ABSORPTION FALLS MONOTONICALLY with the slew, which")
print(" is the OPPOSITE of what SAFETY-39 measured on release/1.0")
print(" (958/1107/1133/1058/987/1029/1150/1143/1081 W, flat and")
print(" non-monotone). Bounding the integrator's lead is what changed")
print(" it: the slew now owns a share of the over-absorption it did")
print(" not own before. DECISIONS/SAFETY-48 §6 has both tables and an")
print(" independent confirmation on test_control.py's own plant.")
else:
print(" ⚠️ NEITHER NARRATION FITS. The law above and the shape of the")
print(f" column ({'falling' if falling else 'not falling'}) are the")
print(" combination this script has no sentence for - which means the")
print(" measurement has moved and DECISIONS/SAFETY-39 §3 and")
print(" DECISIONS/SAFETY-48 §6 both need re-reading before this table")
print(" is quoted anywhere.")
print("\n EITHER WAY THE RIG DOES NOT PICK THE NUMBER, and it is not asked")
print(" to: the CONSTRAINT picks it, and this table is what says what the")
print(" constraint does and does not buy.")
def gain_table(ps, ct, tick_s, rail):