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 index c4a5dc7..1574fa3 100644 --- a/goodwe_controller/DECISIONS/SAFETY-48-bounding-the-integrator-lead.md +++ b/goodwe_controller/DECISIONS/SAFETY-48-bounding-the-integrator-lead.md @@ -19,15 +19,22 @@ does not deploy and does not bump the version. 2. ⚠️ **THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, AND THAT IS WHY IT IS NOT WHAT SHIPPED.** Reusing the asymmetric freeze on a carried slew flag cuts export 13 % and moves the worst quarter-hour average **offtake** - from 3739 W to **3935 W**, with the mean quarter +2.7 W. A frozen integrator - stops at `prev + gain·error`, which undershoots what the site needs by - `1 − gain`, so a large import is answered too weakly for as long as the ramp - lasts. §3. + 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.** Export 39.6 → 34.1 - kWh, wasted throughput 4.67 → 3.19 kWh, sign reversals 2992 → 2770, worst - quarter-hour offtake 3739 → **3434 W** (it improves), median recovery 232 → - 199 s. §4. + 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 @@ -124,15 +131,82 @@ offtake, and form A raises it by **196 W** — and raises the MEAN quarter by 2.7 W, so this is systematic and not one unlucky quarter. It does so in **five of the six** plant and tuning variants in `--sensitivity`; the sixth is a tie. -The mechanism is not subtle once it is stated: a frozen integrator stops at +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`. For an EXPORT that under-correction is free; -for an IMPORT it is offtake, for as long as the ramp lasts. +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 @@ -154,13 +228,23 @@ Four properties, and each of them is a decision: thirty files until a check was written for it.** Applying it to every cycle instead — `i_w = max(target - cap, min(target + cap, i_w))` after `target = slewed`, −92 B — is **bit-identical on the shipped configuration**: - 0 of 256 246 ticks differ over the 16-day recording. The two are separable on - **2050 of 257 400** swept states, every one of them at `integrator_max_w` - ABOVE `max_w` — the configuration `control.py` says is the only way its - `clamped` reason is reachable. There the integrator is *deliberately* allowed - above the rail, and an unconditional cap would drag it back to the command - every cycle and make the option inert. `test_telemetry.py` part 4 drives that - configuration, and the unconditional form is red on it. + 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 @@ -170,6 +254,21 @@ Four properties, and each of them is a decision: 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 @@ -235,22 +334,35 @@ writer holding the pack at +700 W, the command pinned, then released — settles at **−800 W** where release/1.0 settled at −1190 W against an integrator of −1194 W; and its sibling run settles at **−1200 W** where release/1.0 settled at −2390 W. The property AC 4 asserts is unchanged: the released command stops at -the frozen integrator and not at the rail. What changed is how far that -integrator had been allowed to wind. Both literals in `test_control.py` were -re-baselined **with the reason beside them**, and the second is now asserted -against `i_w` rather than against a number. +the 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` leaves SAFETY-13 bit-identical and gives export 34.9 kWh (against -34.07), worst quarter 3461 W (against 3434), wasted 3.5 kWh (against 3.19) — and -a worst observed lead of **4966 W**, i.e. no bound at all on the quantity this +`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. +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 -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. +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 @@ -280,6 +392,25 @@ 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 @@ -304,18 +435,32 @@ 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 | +| L2 | `SLEW_LEAD_STEPS = 0` — the velocity form | +0 B | 4 + 3 | | L3 | `SLEW_LEAD_STEPS = 12` — a cap above the rail | +1 B | 4 + 3 | -| L4 | the cap applied unconditionally, outside the slew branch | −92 B | 1 — **and it was a SURVIVOR of all thirty files before that check existed** | -| L5 | `min`/`max` swapped — the cap on the wrong side | +0 B | 2 + 5, one of them `test_control.py`'s own "frozen integrator never deadlocks" over 13 041 states | -| L6 | the lead measured against `prev_w` rather than the command emitted | +0 B | 4 + 3 | +| 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 L5's reds is `test_control.py`'s own *"frozen integrator never +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: @@ -362,7 +507,16 @@ SENSITIVITY - the ranking, not the watts ``` ⚠️ **`--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. +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 diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 229371c..2049886 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -385,10 +385,28 @@ saturation freeze is holding the command (`Loop reason` says Before this bound existed, the same 6 kW export drove the lead to 4600 W and the integrator to its 5000 W rail, and the command then needed twelve cycles — about a minute at this install's meter rate — to walk back down once the disturbance -cleared. Measured over 16 days of the reference install's own recorded -disturbances, bounding it cut avoidable export by 5.5 kWh, cut wasted battery -throughput from 4.67 to 3.19 kWh, and **lowered** the worst quarter-hour average -offtake — the billed one — from 3739 W to 3434 W. +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 diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index f75c462..fa89464 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -216,36 +216,48 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13 # threshold LOWER, so the case fires more often, not less. # # ⚠️ WHAT IS BOUNDED IS THE LEAD, NOT THE INTEGRATOR. Two forms were measured -# over 15.7 days of the live install's own recorded disturbances +# 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 3839 -> 3935 W and the mean quarter -# moves +2.7 W, in five of six plant and tuning variants. A frozen -# integrator stops at `prev + gain * error(first cycle)`, which UNDER- -# shoots what the site needs by (1 - gain), so a large import is answered -# too weakly for as long as the ramp lasts. That is the wrong trade on a -# capacity tariff and the reason this is not that fix. +# 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.8 -> 34.1 kWh, wasted throughput 4.7 -> 3.2 kWh, sign reversals -# 3050 -> 2806, worst quarter-hour offtake 3839 -> 3434 W (it IMPROVES), -# median recovery 237 -> 214 s. It is what ships. +# 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.1 kWh, better than (b) by 2 kWh, and it +# 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 4.84 s tick. +# 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 @@ -570,16 +582,49 @@ def compute( if slewed != target: reason = "slew-limited" # --- SAFETY-48: the step ceiling reaches the integrator too --------- - # ⚠️ INSIDE THIS BRANCH AND NOWHERE ELSE. The bound is on the lead the - # SLEW created, so it is applied only where the slew is what stopped - # the command. Applied unconditionally it would also pull the - # integrator toward a command the SATURATION freeze is pinning - and - # SAFETY-13's whole design is that the frozen integrator keeps standing - # where it is, so that releasing the freeze buys the standing error - # back. Measured over the recording, the two forms differ on 118 of - # 256 250 ticks and agree on every reported figure to one decimal; the - # narrow one is written because it is the one whose scope matches its - # sentence. + # ⚠️ 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. diff --git a/goodwe_controller/test_telemetry.py b/goodwe_controller/test_telemetry.py index 0ed92f1..caa28c1 100644 --- a/goodwe_controller/test_telemetry.py +++ b/goodwe_controller/test_telemetry.py @@ -51,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 @@ -88,16 +95,28 @@ SAFETY-48's OWN BATTERY, same discipline, against `app/control.py`: until part 4's `integrator_max_w` case existed - L5 `min`/`max` swapped - the cap on the wrong +0 B 2 here + 5 control - side - L6 the lead measured against `prev_w` rather than +0 B 4 here + 3 control + 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 on 2050 of 257 400 -swept states, all at `integrator_max_w` above `max_w`, which is the stimulus -part 4 drives. +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 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):