# TEL-10 — the loop's integrator is published, as three site rows **Status:** decided and implemented on branch `TEL-10`. **Card:** TEL-10 — *"`self.i_w` is carried between cycles and published nowhere"*. **Nothing here reached the inverter, and nothing here can.** `app/control.py` is not touched by this branch; `compute()`, its returned `Decision` and every constant it reads are byte-identical to `release/1.0`. The diff is a controller recording three values it already had, and a publisher announcing three rows. --- ## 0. The three things a reader should take away 1. **The row that matters is the one that is usually indistinguishable from `Setpoint`.** With nothing binding, the command *is* the integrator rounded to 10 W, so on a quiet day publishing the wrong one looks correct. §2 is the fixture that separates them by kilowatts, and §4 is the mutation that proves the separation is what the check reads. 2. **`Loop reason` is a genuinely new sentence, not a second copy of one.** The status page's `Why` row and `sensor.goodwe_controller_status` both carry the *arbiter's* line (`loop -> -5000 W`), which prints the winning claim's NAME. `compute()`'s own reason has never left the process, and `slew-limited` — the value SAFETY-48 is about — appears in neither of the other two. §3. 3. ⚠️ **Three entities are three entities.** This is an addition to a live install's entity list, which TEL-07 refused to make for four per-unit rows at a roster of one. The argument for making it here is in §5, and so is the argument that keeps it to three rather than to one per inverter. --- ## 1. Why this is a card at all `self.i_w` has been a separate quantity from the command since SAFETY-04, which split the accumulator out of the velocity form precisely so the two could be bounded apart. Since then it has been: * carried between cycles by `Controller.cycle()`, * read by nothing outside `compute()`, * and written to no entity, no MQTT topic, no attribute and no log line. The consequence is not theoretical. The investigation behind SAFETY-48 needed to know what the integrator was doing during the export episodes and could only **reconstruct** it, from the grid trace plus a replay of the real `compute()`. The reconstruction reproduced about a third of the engagements it was checked against and was **discarded entirely** — so the card it was gathered for had to be argued from a measurement nobody could stand behind. ⚠️ **And the reconstruction could not have been made to work.** `i_w` at a given instant depends on every cycle before it, including every one in which the arbiter overrode the loop and `main.py` re-seeded `self.i_w = self.target`. That re-seed is invisible in the grid trace. A replay can only recover the integrator on a run where the loop won every arbitration, which is not the run anybody is investigating. ## 2. What is published, and against what Three rows, on the SITE device, appended after `release/1.0`'s eight sensors: | key | entity | what it is | |---|---|---| | `loop_i` | `sensor.goodwe_loop_integrator` | `Decision.i_w` — the accumulator AFTER this cycle, BEFORE the output clamp, the slew limit and quantisation, which is what its own docstring says it is | | `loop_lead` | `sensor.goodwe_loop_integrator_lead` | `decision.i_w - prev_w`, where `prev_w` is the command `compute()` was handed | | `loop_reason` | `sensor.goodwe_loop_reason` | `Decision.reason`, unmodified | ⚠️ **`loop_lead` IS NOT A SUBTRACTION OF THE OTHER TWO ROWS, WHICH IS WHY IT IS A ROW.** `Setpoint` is the ARBITER's output, after every other strategy has had its say, and it is THIS cycle's; the lead is measured against the command the PREVIOUS cycle left standing. On any cycle where the arbiter overrode the loop, subtracting the two published rows gives a different number — and that is exactly the cycle on which `main.py` re-seeds the integrator, i.e. the cycle a reader most needs the real figure for. `cycle()` therefore records all three from the one `compute()` call, and names the command it passes (`loop_prev`) rather than re-reading `self.target` at publish time. `self.target` is reassigned later in the same method, so a lead computed at publish time would subtract a different cycle's command and call the answer a lead. ⚠️ **At the line as written the two expressions are the same value, and the mutation that swaps them is a recorded SURVIVOR** — see §4 M2. The name is kept for the coupling, not for a behaviour it currently buys. ### The not-running branch `cycle()`'s `else` arm — stopped AND shadow mode off — already zeroes `self.i_w`, because no accumulation may survive the outage. The three rows are zeroed and `loop_reason` becomes `not-running` on the same branch. A row that kept repeating the last integrator there would say the loop was winding while nothing is being controlled. ⚠️ **`auto: false` ALONE IS NOT THAT BRANCH.** The shipped `shadow_mode: auto` means a stopped controller still runs the whole decision path and records it (SAFETY-10), so the loop is live and the integrator is real. The first draft of the check asserted `not-running` about a loop that was running; it reads the shadow case as its own claim now. ## 3. Why `loop_reason` is not the row that already exists `self.reason` is `Resolution.explain()`: ``` "loop -> -5000 W" # winner, watts "loop -> -5000 W, limited by soc" # ...and which limit bit "CONTRADICTION between limits - commanding 0 W (loop)" ``` `Claim.set("loop", P_LOOP, decision.target_w, decision.reason, ...)` does carry the loop's reason INTO the arbiter — and `explain()` prints the winner's **source name**, never the claim's reason. So the loop's own word has always been built, handed over, and dropped one line later. Measured on the fixture in `test_telemetry.py` part 2, same cycle: ``` the arbiter's sentence: 'loop -> -5000 W' the loop's own word : 'i-clamped' ``` ## 4. Verified by breaking it Nine mutations, run in the CI image on a purged tree, each fingerprinted before, after, and again after the revert. The table with byte deltas and red counts is in `test_telemetry.py`'s header; two entries are worth repeating here. **M1 — `self.loop_i_w = loop_prev` (−3 B), the card's own named mutation: publish the COMMAND under the integrator's name.** The first draft of the acceptance check was *"the published integrator reaches its bound"*, and that check was **GREEN** under M1 — because the command reaches the bound too, twelve cycles later. MUTATION.md habit 4 in the check written to close the card. It asserts the **lead time** now (the integrator is at the bound at least ten cycles before the command is), and is red on 5 named checks. **M2 — `decision.i_w - self.target` (+2 B) is a SURVIVOR, and is reported as one.** At that line `cycle()` has not yet executed `self.target = resolution.target_w`, so the two expressions denote the same value and the mutation changes nothing that runs. The run was purged, which is not the direction stale bytecode fabricates. M2b (`decision.i_w - decision.target_w`, +8 B) is the mutation that does change the value and is red. ## 5. What was rejected **Publishing as an MQTT attribute on an existing entity.** There is no `json_attributes_topic` anywhere in this add-on; adding one would be a second convention for one card, and HA's recorder keeps long-term statistics for a state with `state_class`, not for an attribute. The reason this card exists is that nobody had history to read. **A status-page row instead.** The page is not recorded. `Why` already carries the arbiter's sentence and stays that way; DOCS.md gains the section that says how to read the three entities. **One integrator row per inverter.** §6.2's architecture is ONE integrator on the P1 meter whose output is then SPLIT. A per-unit copy would invent N of a thing there is one of, and the two copies would disagree the moment the allocator gave the units different shares. `test_mqtt.py` part 5 grades this at every roster length from 1 to 6, and the mutation that moves `loop_i` into `UNIT_SENSORS` is red on 8 named checks. **Adding the rows at N≥2 only, the way TEL-07 gates its per-unit rows.** TEL-07's gate exists because at a roster of one its four rows carry information the site rows already carry exactly. These three carry information NOTHING carries, at every roster length, including one. **A `## Unreleased` block in `CHANGELOG.md`.** `test_version_gate.py` part 11b refuses a changelog whose newest `## ` heading is not `config.yaml`'s `version:` — PKG-14's rule that the bump and the closed section are one act. Opening a section here would mean bumping the version, which is a packaging decision this card does not get to take. The operator-facing account is in DOCS.md; the release note belongs to whichever PKG ticket cuts the next version, and this file is what it should be written from. ## 6. What this card does NOT establish * **It says nothing about whether the integrator's behaviour is right.** That is SAFETY-48. This branch adds an observable and changes no decision; the 15.7-day replay that grades the behaviour is in that card. * **The three rows do not exist on the live install until the add-on is updated**, and this branch does not update it. `tools/rehearsal/announced- reference.txt` (what the BUILD announces) records them; `live-entities- recorded.txt` (what the INSTALL has) deliberately does not, because an id the build announces and the install lacks is a new entity, not an orphan. * **`loop_lead` is signed and is not a distance.** Where the integrator and the command have opposite signs it is the sum of two magnitudes. A reader wanting "how far behind is the command" should take the magnitude and say so.