`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
9.6 KiB
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
- 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. Loop reasonis a genuinely new sentence, not a second copy of one. The status page'sWhyrow andsensor.goodwe_controller_statusboth carry the arbiter's line (loop -> -5000 W), which prints the winning claim's NAME.compute()'s own reason has never left the process, andslew-limited— the value SAFETY-48 is about — appears in neither of the other two. §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_leadis 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.