Files
goodwe-addon/goodwe_controller/DECISIONS/TEL-10-publishing-the-integrator.md
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

9.6 KiB
Raw Permalink Blame History

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.