diff --git a/goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md b/goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md index f6f3d64..fd27c8a 100644 --- a/goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md +++ b/goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md @@ -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 diff --git a/goodwe_controller/DECISIONS/SAFETY-48-bounding-the-integrator-lead.md b/goodwe_controller/DECISIONS/SAFETY-48-bounding-the-integrator-lead.md new file mode 100644 index 0000000..1574fa3 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-48-bounding-the-integrator-lead.md @@ -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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 55e08c9..2049886 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 0f8011a..6716ea0 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -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 diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index 180f58a..fa89464 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -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: diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-batt.jsonl.gz new file mode 100644 index 0000000..ec299c0 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-grid.jsonl.gz new file mode 100644 index 0000000..9abeb36 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-setpoint.jsonl.gz new file mode 100644 index 0000000..907b8c1 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-soc.jsonl.gz new file mode 100644 index 0000000..7934879 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-02-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-batt.jsonl.gz new file mode 100644 index 0000000..9c2fe5d Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-grid.jsonl.gz new file mode 100644 index 0000000..e4bc116 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-setpoint.jsonl.gz new file mode 100644 index 0000000..becce92 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-soc.jsonl.gz new file mode 100644 index 0000000..2fbfd7e Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-03-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-batt.jsonl.gz new file mode 100644 index 0000000..23c61b8 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-grid.jsonl.gz new file mode 100644 index 0000000..77eaffc Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-setpoint.jsonl.gz new file mode 100644 index 0000000..11aab0a Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-soc.jsonl.gz new file mode 100644 index 0000000..0761b52 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-04-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-batt.jsonl.gz new file mode 100644 index 0000000..d09f549 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-grid.jsonl.gz new file mode 100644 index 0000000..6e8304b Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-setpoint.jsonl.gz new file mode 100644 index 0000000..3348591 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-soc.jsonl.gz new file mode 100644 index 0000000..50e907a Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-05-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-batt.jsonl.gz new file mode 100644 index 0000000..aed37af Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-grid.jsonl.gz new file mode 100644 index 0000000..2aad4a5 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-setpoint.jsonl.gz new file mode 100644 index 0000000..64a0216 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-soc.jsonl.gz new file mode 100644 index 0000000..1f0d4a6 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-06-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-batt.jsonl.gz new file mode 100644 index 0000000..742d265 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-grid.jsonl.gz new file mode 100644 index 0000000..9665990 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-setpoint.jsonl.gz new file mode 100644 index 0000000..9ee9ae4 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-soc.jsonl.gz new file mode 100644 index 0000000..6315366 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-07-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-batt.jsonl.gz new file mode 100644 index 0000000..797937b Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-grid.jsonl.gz new file mode 100644 index 0000000..33446f8 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-setpoint.jsonl.gz new file mode 100644 index 0000000..3d2d111 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-soc.jsonl.gz new file mode 100644 index 0000000..99aaf02 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-08-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-batt.jsonl.gz new file mode 100644 index 0000000..e87478b Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-grid.jsonl.gz new file mode 100644 index 0000000..fdc4cf8 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-setpoint.jsonl.gz new file mode 100644 index 0000000..6fbfdbc Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-soc.jsonl.gz new file mode 100644 index 0000000..89243dc Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-09-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-batt.jsonl.gz new file mode 100644 index 0000000..9147fdb Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-grid.jsonl.gz new file mode 100644 index 0000000..4a0dd55 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-setpoint.jsonl.gz new file mode 100644 index 0000000..72573c8 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-soc.jsonl.gz new file mode 100644 index 0000000..fec6e52 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-10-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-batt.jsonl.gz new file mode 100644 index 0000000..e04712b Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-grid.jsonl.gz new file mode 100644 index 0000000..0a9d831 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-setpoint.jsonl.gz new file mode 100644 index 0000000..d93ddd9 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-soc.jsonl.gz new file mode 100644 index 0000000..f348b30 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-11-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-batt.jsonl.gz new file mode 100644 index 0000000..62646a5 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-grid.jsonl.gz new file mode 100644 index 0000000..e8e4e2e Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-setpoint.jsonl.gz new file mode 100644 index 0000000..78d9034 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-soc.jsonl.gz new file mode 100644 index 0000000..6c64e6e Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-12-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-batt.jsonl.gz new file mode 100644 index 0000000..7f248d2 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-grid.jsonl.gz new file mode 100644 index 0000000..a8398ee Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-setpoint.jsonl.gz new file mode 100644 index 0000000..0e30392 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-soc.jsonl.gz new file mode 100644 index 0000000..16c0989 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-13-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-batt.jsonl.gz new file mode 100644 index 0000000..14ae42b Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-grid.jsonl.gz new file mode 100644 index 0000000..edae355 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-setpoint.jsonl.gz new file mode 100644 index 0000000..1635a18 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-soc.jsonl.gz new file mode 100644 index 0000000..724ff30 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-14-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-batt.jsonl.gz new file mode 100644 index 0000000..655c787 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-grid.jsonl.gz new file mode 100644 index 0000000..41ad63e Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-setpoint.jsonl.gz new file mode 100644 index 0000000..38e4c49 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-soc.jsonl.gz new file mode 100644 index 0000000..7ab103d Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-15-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-batt.jsonl.gz new file mode 100644 index 0000000..cf3683e Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-grid.jsonl.gz new file mode 100644 index 0000000..797c139 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-setpoint.jsonl.gz new file mode 100644 index 0000000..2392db9 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-soc.jsonl.gz new file mode 100644 index 0000000..28ff51a Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-16-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-batt.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-batt.jsonl.gz new file mode 100644 index 0000000..af4e477 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-batt.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-grid.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-grid.jsonl.gz new file mode 100644 index 0000000..3b01fd9 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-grid.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-setpoint.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-setpoint.jsonl.gz new file mode 100644 index 0000000..33958f5 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-setpoint.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-soc.jsonl.gz b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-soc.jsonl.gz new file mode 100644 index 0000000..0e9f083 Binary files /dev/null and b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/2026-09-17-soc.jsonl.gz differ diff --git a/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/manifest.json b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/manifest.json new file mode 100644 index 0000000..f403a1a --- /dev/null +++ b/goodwe_controller/scenarios/loop-2026-09-02_2026-09-17/manifest.json @@ -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\": , \"v\": } 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" + } + } + } + ] +} diff --git a/goodwe_controller/test_control.py b/goodwe_controller/test_control.py index 976c1b4..08c79ac 100644 --- a/goodwe_controller/test_control.py +++ b/goodwe_controller/test_control.py @@ -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", diff --git a/goodwe_controller/test_telemetry.py b/goodwe_controller/test_telemetry.py index b93d9ce..caa28c1 100644 --- a/goodwe_controller/test_telemetry.py +++ b/goodwe_controller/test_telemetry.py @@ -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: diff --git a/goodwe_controller/tools/replay_recorded_loop.py b/goodwe_controller/tools/replay_recorded_loop.py new file mode 100644 index 0000000..215ccd1 --- /dev/null +++ b/goodwe_controller/tools/replay_recorded_loop.py @@ -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, "", "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": , "v": } 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()) diff --git a/goodwe_controller/tools/slew_sim.py b/goodwe_controller/tools/slew_sim.py index ea6c51b..1d91702 100644 --- a/goodwe_controller/tools/slew_sim.py +++ b/goodwe_controller/tools/slew_sim.py @@ -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):