Files
goodwe-addon/goodwe_controller/DECISIONS/TEL-10-publishing-the-integrator.md
T
glenn schrooyenandClaude Opus 5 b548525d10 TEL-10: the loop's integrator stops being invisible
`self.i_w` has been a separate quantity from the command since SAFETY-04 and
has left the process nowhere at all - no entity, no MQTT 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 the card it was
gathered for 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 `self.i_w = self.target`, and that re-seed is invisible in
the grid trace.

THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is
`Decision.i_w` - the accumulator after the cycle, before the output clamp, the
slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the
command `compute()` was HANDED, which is not a subtraction of the other two
published rows: `Setpoint` is the arbiter's output and is this cycle's, so on
any cycle the arbiter overrode the loop the two differ - and that is exactly
the cycle on which the integrator was re-seeded. `loop_reason` is
`compute()`'s own word; the arbiter's `explain()` prints the winning claim's
NAME and has never carried the claim's reason, so `slew-limited` appears in
neither the `Why` row nor `Controller status`. Measured on the same cycle:
arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.

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, and all nine of release/1.0's retained discovery messages are
byte-identical and still FIRST in the announcement.

Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor,
reported as one: `decision.i_w - self.target` for the lead is +2 B and green
because `cycle()` has not reassigned `self.target` at that line - the name is
kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the
mutation that does change the value and is red.

AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED.
"the published integrator reaches its bound" was GREEN under the card's named
mutation (publish `prev_w`, -3 B), because the command reaches the bound too -
twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped
controller publishes not-running" was GREEN under deleting that branch's
assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__`
writes the same values; it stops the controller part-way through a run that
has already wound the integrator to -5000 W. Both are MUTATION.md habit 4,
found by the battery rather than by review.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## `
heading that is not config.yaml's `version:`, and opening one would mean
taking a packaging decision this card does not get to take. The operator
account is in DOCS.md and the release note belongs to the next PKG ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-18 23:36:23 +02:00

183 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.