diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 214e969..e5e1b23 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -87,6 +87,17 @@ check. **and** is refused by name (rule 11), so the reinterpretation is never silent; no blob written before 0.10.0 can carry one. +⚠️ **AND 0.10.0 CHANGES THE CONTROL LAW ITSELF, WHICH NOTHING ABOVE AND NO +CONFIG CHECK WILL TELL YOU ABOUT.** [[SAFETY-48]] bounds how far the +integrator may run ahead of the command on the cycles where the slew limit is +what stopped the command moving. It adds no option, moves no default and +refuses no blob, so every check in this preamble passes it in silence - and it +is the one change here that alters what the inverter is ASKED TO DO on a tree +that is otherwise configured identically. ⚠️ **What it is measured to buy is +less EXPORT. It is NOT a lower capacity-tariff peak, and your billed quarter +should not be expected to improve.** The whole account, including what it costs +and what it does not establish, is `### SAFETY-48` below. + ⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS - THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha addons update` does not walk the versions; it installs 0.10.0 in one step, so @@ -120,6 +131,21 @@ BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both. live options pass this version's validator CLEARED for deploy. +⚠️ **THAT RUN WAS MADE ON PKG-14'S TREE, AND [[PKG-15]] DID NOT RE-RUN IT. +HERE IS WHY IT STILL HOLDS, CHECKED RATHER THAN ASSUMED.** The two tickets +folded in below touch three files between them - `app/control.py`, +`app/main.py` and `app/mqtt.py` - and **`config.yaml` is not among them**, so +the option surface and the 75 defaults the blob is merged over are byte-for- +byte what that run measured. `app/validate.py` imports neither `main` nor +`mqtt`; from `control` it imports exactly `SLEW_RAIL_FRAC`, `LOOP_TICK_S` and +`PLANT_SETTLE_S`, and all three are **unchanged** (`2.0`, `18.0`, and their +quotient) - [[SAFETY-48]] adds `SLEW_LEAD_STEPS` and reads it inside +`compute()`, which the validator never calls. ⚠️ **And the tool's OTHER half - +the no-op check - does not run at all here**, because it is gated on +`shipped == deployed` and the box reports `0.7.0`. It is the half that would +refuse this fold on a box already running 0.10.0; see the note below the +contents table. + **The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four new keys, and none of them can refuse a blob written before it existed: `price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` - @@ -158,6 +184,29 @@ image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime commits since `86bdf49`**, of which the table below accounts for 21. +⚠️ **RE-MEASURED BY [[PKG-15]] ON 2026-09-19, AFTER FOLDING [[TEL-10]] AND +[[SAFETY-48]] INTO THIS SECTION: 32 RUNTIME COMMITS SINCE `86bdf49`, OF WHICH +THE TABLE BELOW NOW ACCOUNTS FOR 24.** Re-derived with `is_runtime()` itself +rather than by adding to the old figure. The other eight are the three misfiled +tickets in the table above - SAFETY-43 two commits, SAFETY-44 three, TEST-21 +two - plus `cb3f686`, PKG-14's own bump, which edits `config.yaml` and is +therefore a runtime commit by that function's rule; it was `HEAD` when PKG-14 +counted, so `last..HEAD` excluded it then and includes it now. The three +misfiled tickets are still misfiled and still [[PKG-08]]'s card; nothing below +moves them. + +⚠️ **AND THE VERSION IS NOT BUMPED FOR THIS FOLD, DELIBERATELY.** 0.10.0 was +cut on 2026-09-16 and **has never been built or pulled anywhere** - the +reference install is on 0.7.0, this repository carries exactly one tag +(`v0.2.1`) and no release - so the string `0.10.0` does not yet name a tree +anyone is running, and the PKG-06/PKG-07 no-op that forbids re-cutting 0.9.0 +does not apply to it. Cutting 0.11.0 instead would burn a number on a release +that never left the repository. ⚠️ **THE ARGUMENT IS CONDITIONAL AND THE +CONDITION IS CHECKABLE**: if 0.10.0 has reached an install by the time you read +this, it may not be folded into again, and `tools/predeploy_check.py` says so +by itself - run against a box already reporting `0.10.0` it refuses with `THE +DEPLOY WOULD BE A NO-OP` and names the runtime commits since the bump. + Contents of this release, in merge order since 0.9.0 (`86bdf49`): | | | @@ -180,6 +229,175 @@ Contents of this release, in merge order since 0.9.0 (`86bdf49`): | `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** | | `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** | | `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** | +| `660d079` | TEL-10 - the loop's integrator stops being invisible: three new site sensors **⚠️ THREE NEW ENTITIES** | +| `186a044` | SAFETY-48 - the integrator's lead over the command is bounded at one slew step **⚠️ THE CONTROL LAW CHANGED** | + +⚠️ **[[CI-9]] ALSO MERGED INTO `release/1.0` IN THIS WINDOW AND IS +DELIBERATELY NOT IN THE TABLE ABOVE, NOR ANYWHERE ELSE IN THIS FILE.** It +changes exactly one file, `.gitea/workflows/checks.yml`, which is not under +`goodwe_controller/` at all: it is not in the built image, it is not in +`predeploy_check.py`'s `RUNTIME` list, and there is no version of this add-on +on which an operator can observe it. This file is the **add-on's** release note +- the text the Supervisor puts beside the version number - and the rule PKG-15 +applied is that a row here has to be something the person deciding whether to +press *Update* could act on. CI-9 is not; the repository's history and PR #95 +are where it is written down, and they have it in full. ⚠️ **THE RULE IS ABOUT +THE AUDIENCE, NOT ABOUT IMPORTANCE.** CI-9 repaired a merge gate that was +grading the wrong tree, which is the same class of defect this ticket exists to +clean up after - and it is still not an operator-facing change. If a future CI +ticket changes what the shipped image contains, it belongs here; this one does +not. + +### SAFETY-48 — the integrator's lead over the command is bounded, and the control law changed + +⚠️ **THIS CHANGES WHAT THE INVERTER IS ASKED TO DO.** It is the only entry in +0.10.0 that does so without touching an option, a default or the validator, so +nothing in this section's preamble and no run of `tools/predeploy_check.py` +will warn you about it. `app/control.py` is in the diff; `config.yaml` is not. + +**The defect is a missing case, not a wrong constant.** `compute()` freezes the +integrator when the inverter is not following, and the comment beside that +block states the requirement outright — *the INTEGRATOR must stop accumulating, +not only the command*. But `frozen` was computed from one signal, +`abs(prev_w - actual_w) > saturation_w`, and **slew-limiting never entered it**. +The integrator moves `gain × error` per cycle while the command may move at +most `slew_w`, so the integrator outruns the command whenever +`|error| > slew_w / gain` — **667 W** at the live tuning, against a measured +median grid error of **1114 W** at the onset of a recorded episode. ⚠️ +[[SAFETY-39]]'s retune made this **more** frequent, not less: a smaller step is +a lower threshold. + +**What ships** bounds the integrator's **lead over the command** at one slew +step, inside the branch where the slew is what bound the command, and only in +the leading direction. Nothing else in the law moves: the output clamp, the +saturation freeze, the deadband and the 10 W quantisation are unchanged, and +`test_control.py`'s exhaustive `i_w=None` equivalence sweep against the +transcribed release/1.0 law still passes untouched — the cap moves `i_w`, not +`target`. + +⚠️ **WHAT IT IS MEASURED TO BUY IS LESS EXPORT — AND EXPLICITLY NOT A LOWER +CAPACITY-TARIFF PEAK. DO NOT EXPECT YOUR BILLED QUARTER TO IMPROVE.** Replayed +over 256 252 recorded meter instants spanning 16.00 days of this install +(`scenarios/loop-2026-09-02_2026-09-17`, committed), same ticks, same +disturbance, same plant, same seed: + +| | release/1.0 | SAFETY-48 | +|---|---:|---:| +| **export kWh** | **39.61** | **34.07** | +| wasted throughput kWh | 4.67 | 3.19 | +| battery sign reversals | 2992 | 2770 | +| median recovery s | 232 | 199 | +| unrecovered (>900 s) | 53 | 31 | +| worst \|i_w − prev_w\| W | 9200 | 800 | + +That is **5.54 kWh less export — 14 % of the replayed figure**, and ⚠️ **the +direction is what is robust, not the size**: across every plant and tuning +variant swept it falls, by between ~4 % (at `gain: 0.3`) and ~16 % (at a +re-fitted gain/offset), the median variant landing near an eighth. At `gain: 0.3` and at `slew_w` set to the rail the +mean-quarter effect is a tie (−0.17 W and **+0.16 W**), because +`|error| > slew_w / gain` is rarely satisfied in those regimes and there is +little for the cap to bite on. + +⚠️ **AND THE BILLED QUARTER IS NOT A CLAIM THIS RELEASE MAKES.** The A/B above +also shows the worst 15-minute average **offtake** moving 3739 → 3434 W, and +that figure exists at **one plant model**. `fit_plant()` selects a 7.5 s +inverter lag on battery-power RMS, which separates it from 2.5 s by **0.3 %**, +while the instrument's own fidelity measure — replayed export against the +**29.43 kWh this install actually exported** — prefers 2.5 s by the whole 35 % +gap. Swept properly: **at every lag ≤ 5.0 s, including the one that best +reproduces the recorded export, release/1.0's worst quarter is already 3434 W +and this change moves it by exactly zero.** The lag is not identified by this +recording, and identifying it needs a driven step response that does not exist +yet. **What this release can say about the billed quantity is that it does not +make it worse at any lag tried — not that it improves it.** + +⚠️ **AND EVERY NUMBER ABOVE IS SIMULATED.** The replay is a first-order plant +model with a 375 W RMS residual; it prints its own fidelity first and it +reproduces **39.6 kWh** of export where the install exported **29.4**, i.e. it +is ~35 % pessimistic about the loop's tracking. Only the **delta** is claimed. +Nothing here drove an inverter. + +**What it costs, stated rather than smoothed:** + +* ⚠️ **The cap does move a saturation-frozen integrator**, because it runs + before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks + it 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 own rule forbids. Worst + case **2424 W**. It never overrides a *same-cycle* refusal. +* **Two committed numbers moved.** [[SAFETY-13]]'s released command settles at + −800 W and −1200 W where release/1.0 settled at −1190 W and −2390 W, because + there is less wound integrator to settle onto; the property asserted is that + it stops at the frozen integrator rather than at the rail. +* ⚠️ **[[SAFETY-39]]'s claim that "the slew does not own the over-absorption" + is now FALSE — inverted**, on the independent first-order plant + `test_control.py` writes for itself: 1005/1109/1285/1330 W rising with a + slower slew becomes 729/550/424/326 falling. Two plant models that share + nothing agree on the sign. SAFETY-39's record carries a forward reference to + this one. +* **The form the card originally asked for was rejected on measurement**, and + this is the least lag-sensitive result in the work: reusing the asymmetric + freeze on a carried slew flag moves the worst quarter-hour offtake + 3739 → **3935 W** at the fitted plant, and across the lag sweep it is the + worst of the three at **every lag from 2.5 s upward** — 3732 / 3711 / 3935 / + 4326 W against release/1.0's 3434 / 3434 / 3739 / 3954 — with a tie at lag + 0.0, and it is worse at **every top-N cut** of the quarters. It also raises + the mean quarter (+2.73 W). + +`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` carries all of it, +including §8, *what this card does NOT establish*. + +### TEL-10 — the loop's integrator stops being invisible + +**Three new MQTT sensors, and nothing in the control path moved.** +`app/control.py` is not in this diff: `compute()`, its returned `Decision` and +every constant it reads are identical to release/1.0. + +`self.i_w` has been a separate quantity from the command since [[SAFETY-04]] +and left the process **nowhere at all** — no entity, no attribute, no log line. +The investigation behind SAFETY-48 could only *reconstruct* it from the grid +trace plus a replay; the reconstruction reproduced about a third of the +engagements it was checked against and was discarded, so a control card had to +be argued from a measurement nobody could stand behind. It could not have been +made to work, either: `i_w` depends on every cycle before it, including the +ones where the arbiter overrode the loop and `main.py` re-seeded it — and that +re-seed is invisible in the grid trace. + +| entity | topic | unit | what it is | +|---|---|---|---| +| `Loop integrator` | `goodwe_loop_integrator` | W | the accumulator **after** this cycle — before the output clamp, the slew limit and quantisation | +| `Loop integrator lead` | `goodwe_loop_integrator_lead` | W | that value minus **the command `compute()` was handed**, i.e. the one the previous cycle left standing | +| `Loop reason` | `goodwe_loop_reason` | — | the control law's own word for this cycle: `tracking`, `deadband`, `i-clamped`, `clamped`, `slew-limited`, `saturated-freeze`, `freeze-released` or `not-running` | + +⚠️ **`Loop integrator lead` IS NOT A SUBTRACTION OF TWO PUBLISHED ROWS, WHICH +IS WHY IT IS ITS OWN ENTITY.** `Setpoint` is the **arbiter's** output after +every strategy has had its say, and it is **this** cycle's; the lead is +measured against the command `compute()` was given, which is the previous one. +On any cycle the arbiter overrode the loop the two differ — and that is exactly +the cycle on which the integrator was re-seeded, so a dashboard doing the +subtraction would get a different number precisely where it matters. + +⚠️ **`Loop reason` IS NOT THE `Why` ROW AND NOT `Controller status`.** Both of +those carry the *arbiter's* sentence — `loop -> -5000 W` — which names the +winning claim and its watts and has never carried that claim's own reason. +`slew-limited`, the value [[SAFETY-48]] above is entirely about, appears in +neither. Measured on one cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`. + +⚠️ **NOTHING EXISTING IS RENAMED, RE-TYPED OR RE-ORDERED.** The three are +**appended** after the nine messages release/1.0 already announces, and all +nine stay **byte-identical**. ⚠️ Eight of them also stay **first**, which is +what `test_mqtt.py` part 1's prefix comparison grades; the ninth, +`goodwe_ctl/availability`, is byte-identical but deliberately moves to **last**, +so a prefix comparison cannot cover it and the check on the next line pins it +in its new position instead. **They are SITE rows, not per-unit ones** — there +is one integrator, so a per-unit copy would invent N of a thing there is one of +— and they therefore appear at **every** roster length, **including a roster of +one**, where the per-unit machinery announces nothing new at all. + +Both numbers carry `state_class: measurement`, so the recorder keeps long-term +statistics for them. `DOCS.md` has the operator account, including how to read +a standing lead: the cycles needed to unwind it are that lead divided by +`slew_w`. ### SAFETY-47 — what a shadow run can show you, and the watts on its status row