TEL-12: several PV inverters, and one absent member is an absent TOTAL #116

Merged
admin merged 1 commits from TEL-12 into release/1.0 2026-09-24 22:41:20 +02:00
Owner

0.11.0 shipped pv_entity as a single str?. The owner of the reference site
reported within minutes of it going live that they have several PV inverters
and could configure one. That is ordinary for a multi-string or
multi-orientation roof, not a property of one house — and OPT-10 is blocked on
this and cannot start recording until it lands.

Branched off release/1.0 at cccb2fa. Not deployable as merged:
config.yaml stays at 0.11.0, so predeploy_check.py reports a NO-OP until
PKG cuts a version.


⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL

Three inverters configured, one unavailable. The sum of the other two is a
smaller number indistinguishable from a real one.
Read back by OPT-10 that
says "there was less PV than there was" — the "no irradiance" conclusion the
channel exists to test rather than to manufacture.

Two designs were genuinely available. (a) publish the total as absent.
(b) publish the partial sum alongside a count of how many contributed.

(a) shipped, and it wins on the one question that matters — who reads the
number, and when
:

  • The MQTT row is read back cold, a week later, by OPT-10 and by anything
    plotting sensor.goodwe_pv_power. A partial sum there is believable and wrong,
    and the count that would qualify it lives in a second entity with its own
    history
    — so trusting the total means joining two recorder series by
    timestamp to learn which samples to distrust. Nobody re-derives that. A gap
    makes the attribution say "I do not know"; a partial makes it say something
    false.
    A count would also carry no information in the state (a) admits: the
    total is published only when every entity contributed, so the count would be
    the constant len(roster).
  • The status page is read live, by a human who wants the diagnostic. So
    the partial information is not thrown away — the row names every entity
    that reported with its watts, every one that did not with its reason, and says
    plainly that there is no site total.
PV production: NO SITE TOTAL - only 2 of 3 entities are reporting and a partial
sum would understate production: sensor.pv_west - no state (entity missing, or
the read failed). Reporting: sensor.pv_east 1200 W, sensor.pv_garage 450 W

This extends TEL-11's rule rather than weakening it. ABSENT_AS_UNKNOWN is
still exactly one key and publish()'s None handling is untouched — the
line that turns an absence into HA's unknown did not have to change, because
"absence is never zero" already covered this one layer up.

⚠️ The mirror matters as much. A member honestly reporting 0 W is
summed — the test is watts is None, never falsiness. Shading, a tripped
string and nightfall are all real zeros, and blanking the site on a zero would
report an absence on every clear evening.

⚠️ Not solved by documenting a template sensor. DOCS.md says so explicitly:
a template sensor summing an unavailable source silently yields the partial
total — the same defect one layer down, where this add-on cannot see or report it.

The surface — measured, not preferred

pv_entity could not become a list. tools/supervisor_shape_probe.py gains
a §G that runs Supervisor 2026.08.0's own validator against this manifest:

candidate verdict
pv_entity becomes a list refused — Invalid list for option 'pv_entity' on the string an 0.11.0 install has stored. The add-on would not start after the update
…with a pv_entity: [] default to rescue it still refused — deepmerge's list strategy is override, so the operator's string survives the merge
one key, both shapes: match(.*)? accepted, and rejected for a different measured reason — it validates nothing (a string, a list, and the integer 417 all pass) and the Configuration tab renders it as a plain text box, so no second inverter can be entered in the UI
pv_entities beside pv_entity ships. The 0.11.0 blob validates untouched, three entities validate, the UI renders multiple: true

pv_entities: [] in options: is load-bearing, and §G measures it with CFG-04
§B's isolated counterfactual: the shipped defaults minus only that key are
refused with Missing option 'pv_entities' in root. A downgrade drops the
key and leaves pv_entity standing, so rolling back is not the trap in reverse.

pv_entities REPLACES pv_entity (CFG-04's rule for inverters:) with a
status caution naming which key won. A union would double-count the
inverter the old key still names — the install this card exists for has
pv_entity set, and the natural upgrade action is to list all of them.
The caution is never a refusal to start: validate.py still does not mention
either key.

Still not a control input

tools/tel11_pv_trace.py extended, not replaced (the name is quoted by
TEL-11's record and test_pv.py's table). Three document sets per tree —
unset, one, multi — and five graded claims. The roster cells live in their
own document precisely so unset and one keep the hashes TEL-11 recorded.

control   A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   A one   846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   B one   846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
control   B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
OK 1/5 UNSET: byte-identical to the baseline tree, control AND telemetry.
OK 2/5 ONE ENTITY: byte-identical to 0.11.0's single-entity path.
OK 3/5 CONTROL, ONE ENTITY: byte-identical to `unset`.
OK 4/5 CONTROL, THREE ENTITIES: 30 cells over 5 grid worlds — three healthy, one
       dead at EVERY position, all dead, one honestly 0 W — every one equal to
       its grid world's unset document AND to the BASELINE tree's.
OK 5/5 CONTROL OF THE CONTROL: telemetry DOES move (30 cells), and no cell with
       a dead member carries the partial sum, while every cell whose members all
       ANSWER publishes its total.
$ echo $?  ->  0

The control hash is unmoved: 846a10a5b89e…. ⚠️ 2/5 is a claim TEL-11
could not make
— its baseline had no channel to be identical to; TEL-12's is
0.11.0, so "one entity behaves exactly as 0.11.0 does" is a real cross-tree
identity over both documents. pv.total() passes a single entity's (watts, why) through verbatim and checks() keeps 0.11.0's two f-strings in a
len(...) == 1 branch for exactly that reason.

⚠️ TEL-11's --compare refusal had to change, and that is a finding. Its
rule was "exactly one side's set differs from its own unset — that side is
the HEAD"
, correct while release/1.0 predated pv_entity. release/1.0 is
0.11.0 now, both sides react, and that refusal fires on every honest run.
The discriminator is the roster instead, measured from the documents rather
than taken from tree.supports_roster (both are printed side by side).
MUTATION.md habit 24.

Mutation battery — twelve, no survivors

Each on a fresh cp -a of the clean worktree (never reverted — habit 18 /
TARIFF-09's method), purged before and checked for a cache after, byte delta
from stat with the edit confirmed on disk by cmp, suites run in the CI image
with checks.yml's own command one file at a time under :ro.

mutation bytes reds
N1 total() returns the partial sum +42 B 6 named + 1 crash
N1b ⭐ THE REJECTED DESIGN: partial sum + a contributor count −282 B 10 named + the trace REFUSED 5/5
N2 absence tested by falsiness (not w) −4 B 2 named
N3 pv_entities UNIONS with pv_entity −27 B 3 named
N4 the de-duplication dropped −15 B 1 named
N5 sum → max over the roster +0 B 6 named
N6 the roster-of-one stops passing why through +13 B 8 named
N7 optional_keys() back to reading pv_entity −6 B 1 named + 1 crash
N8 self.grid -= self.pv_w in read_inputs() +106 B 3 named + trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5)
N9 the roster caution made a bad row (NOT READY) +1 B 1 named
N10 entity_is_ignored() always False +10 B 2 named
N11 pv_entities: [] deleted from options: −18 B 2 in test_translations.py + 11 CASE(S) FAILED in the probe

⚠️ N5 is +0 B — sum and max are the same three characters, so neither
size nor mtime moved and TEST-15's defect is exactly what manufactures a
survivor there. Fresh purged copy, cmp first.

⚠️ N1b is the row that matters, and it is not a defect. It is the other
defensible design, implemented and run — the point being that the checks and the
instrument can tell the two apart:

REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'),
('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'), …]

⚠️ N8's 1/5 stays GREEN and that is correct rather than a miss: with no PV
entity configured pv_w is None and the leak cannot fire — which is what makes
1/5 a claim about the default and 2/5–4/5 the claims about the channel.

The fixture values are the design

⚠️ Habit 23, in the arithmetic rather than in the plant. A member
contributing 0 W could go absent without moving the sum of the rest, so a
fixture like that cannot tell "published the absence" from "published the
partial". test_pv.py's roster is 1200 W + 2.1 kW + 450 W = 3750 W — three
distinct non-zero contributions in two units, separable from max/min/first/last/
mean/count and from each of its own partials (2550 / 1650 / 3300), with that
family computed, printed and asserted non-colliding as its own precondition
(habit 10). Absence is driven at every position of the roster, and each
position's partial is derived from the fixture rather than typed beside it.

Part 6 uses a second roster (1500 + 4.5 kW + 2000 = 8000 W) sized so every
partial would still move a loop settling on a 600 W import, and asks habit
23's own question twice — shift the meter by 8000 W, and again by 3500 W, the
smallest partial any single dead member could produce.

Two false results this card produced, recorded rather than tidied away

  1. The stimulus annotations were first written inside each telemetry cell —
    a fact about the stimulus, identical on both trees, so every roster cell
    differed for a reason that was not behaviour and the discriminator fired on
    both sides.
  2. The WEST-ZERO cell had its true total labelled as its "partial", so 5/5
    refused the shipped tree for publishing a number it is required to publish.

Both were caught by running the instrument against the shipped tree before
running it against a mutant. Both are in MUTATION.md habit 24 and
DECISIONS/TEL-12-several-pv-inverters.md §4.

Checks

31/31 in the CI image, checks.yml's exact command, one file at a time,
:ro, under /home/homelab/. No contact with 192.168.2.158 or 192.168.2.221 at
any point; no ha commands, no /addons/ writes, no deploys.

⚠️ A CHANGELOG entry is wanted and deliberately not written

test_version_gate.py 11b forbids an ## Unreleased heading and the bump is
PKG's call. The option surface moves 76 keys → 77. The material an entry
needs is DECISIONS/TEL-12-several-pv-inverters.md.

What in the brief turned out to be wrong

  • "tools/tel11_pv_trace.py … Reuse it rather than writing a second tool."
    Reused — but its --compare could not be reused as written. Its refusal
    was defined against a baseline that no longer exists, and it refuses the
    shipped tree on every honest run since TEL-11 merged. Replaced with a
    discriminator that survives the next merge, and written up as habit 24.
  • "If the schema cannot accept both shapes, say so plainly." It can — the
    match(.*)? matcher accepts a string, a list and an integer alike. Rejecting
    option 1 for "no spelling accepts both" would have been a fabricated
    reason
    , so §G measures and reports that case even though it passes. The real
    reason is that the spelling which accepts both validates nothing and cannot be
    rendered as a repeatable field.
  • "habit 23 … a fixture must not sum values at a point where a partial and a
    full total produce the same published result."
    Exactly right, and it landed on
    the instrument rather than on a check: labelling a zero-member cell's true
    total as its "partial" produced a false red on the shipped tree.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

0.11.0 shipped `pv_entity` as a single `str?`. The owner of the reference site reported **within minutes of it going live** that they have several PV inverters and could configure one. That is ordinary for a multi-string or multi-orientation roof, not a property of one house — and **OPT-10 is blocked on this and cannot start recording until it lands.** Branched off `release/1.0` at `cccb2fa`. **Not deployable as merged:** `config.yaml` stays at `0.11.0`, so `predeploy_check.py` reports a NO-OP until PKG cuts a version. --- ## ⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL Three inverters configured, one `unavailable`. The sum of the other two is **a smaller number indistinguishable from a real one.** Read back by OPT-10 that says *"there was less PV than there was"* — the "no irradiance" conclusion the channel exists to **test** rather than to manufacture. Two designs were genuinely available. **(a)** publish the total as absent. **(b)** publish the partial sum alongside a count of how many contributed. **(a) shipped**, and it wins on the one question that matters — *who reads the number, and when*: * The **MQTT row** is read back **cold**, a week later, by OPT-10 and by anything plotting `sensor.goodwe_pv_power`. A partial sum there is believable and wrong, and the count that would qualify it lives in a **second entity with its own history** — so trusting the total means joining two recorder series by timestamp to learn which samples to distrust. Nobody re-derives that. **A gap makes the attribution say "I do not know"; a partial makes it say something false.** A count would also carry no information in the state (a) admits: the total is published only when *every* entity contributed, so the count would be the constant `len(roster)`. * The **status page** is read **live**, by a human who wants the diagnostic. So the partial information is **not thrown away** — the row names every entity that reported with its watts, every one that did not with its reason, and says plainly that there is no site total. ``` PV production: NO SITE TOTAL - only 2 of 3 entities are reporting and a partial sum would understate production: sensor.pv_west - no state (entity missing, or the read failed). Reporting: sensor.pv_east 1200 W, sensor.pv_garage 450 W ``` This **extends** TEL-11's rule rather than weakening it. `ABSENT_AS_UNKNOWN` is still exactly one key and `publish()`'s `None` handling is **untouched** — the line that turns an absence into HA's `unknown` did not have to change, because "absence is never zero" already covered this one layer up. ⚠️ **The mirror matters as much.** A member honestly reporting **0 W** is **summed** — the test is `watts is None`, never falsiness. Shading, a tripped string and nightfall are all real zeros, and blanking the site on a zero would report an absence *on every clear evening*. ⚠️ **Not solved by documenting a template sensor.** DOCS.md says so explicitly: a template sensor summing an `unavailable` source silently yields the partial total — the same defect one layer down, where this add-on cannot see or report it. ## The surface — measured, not preferred `pv_entity` **could not** become a list. `tools/supervisor_shape_probe.py` gains a **§G** that runs Supervisor 2026.08.0's own validator against this manifest: | candidate | verdict | |---|---| | `pv_entity` becomes a list | **refused** — `Invalid list for option 'pv_entity'` on the string an 0.11.0 install has stored. The add-on would not start after the update | | …with a `pv_entity: []` default to rescue it | still refused — deepmerge's list strategy is `override`, so the operator's string survives the merge | | one key, both shapes: `match(.*)?` | **accepted, and rejected for a different measured reason** — it validates *nothing* (a string, a list, and the integer `417` all pass) and the Configuration tab renders it as a plain text box, so no second inverter can be entered in the UI | | **`pv_entities` beside `pv_entity`** | **ships.** The 0.11.0 blob validates untouched, three entities validate, the UI renders `multiple: true` | `pv_entities: []` in `options:` is load-bearing, and §G measures it with CFG-04 §B's isolated counterfactual: the shipped defaults **minus only that key** are refused with `Missing option 'pv_entities' in root`. A downgrade **drops** the key and leaves `pv_entity` standing, so rolling back is not the trap in reverse. **`pv_entities` REPLACES `pv_entity`** (CFG-04's rule for `inverters:`) with a status **caution** naming which key won. A union would **double-count** the inverter the old key still names — the install this card exists for has `pv_entity` set, and the natural upgrade action is to list all of them. The caution is never a refusal to start: `validate.py` still does not mention either key. ## Still not a control input `tools/tel11_pv_trace.py` **extended, not replaced** (the name is quoted by TEL-11's record and `test_pv.py`'s table). Three document sets per tree — `unset`, `one`, `multi` — and five graded claims. The roster cells live in their own document precisely so `unset` and `one` keep the hashes TEL-11 recorded. ``` control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30 control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30 OK 1/5 UNSET: byte-identical to the baseline tree, control AND telemetry. OK 2/5 ONE ENTITY: byte-identical to 0.11.0's single-entity path. OK 3/5 CONTROL, ONE ENTITY: byte-identical to `unset`. OK 4/5 CONTROL, THREE ENTITIES: 30 cells over 5 grid worlds — three healthy, one dead at EVERY position, all dead, one honestly 0 W — every one equal to its grid world's unset document AND to the BASELINE tree's. OK 5/5 CONTROL OF THE CONTROL: telemetry DOES move (30 cells), and no cell with a dead member carries the partial sum, while every cell whose members all ANSWER publishes its total. $ echo $? -> 0 ``` **The control hash is unmoved: `846a10a5b89e…`.** ⚠️ **2/5 is a claim TEL-11 could not make** — its baseline had no channel to be identical to; TEL-12's is 0.11.0, so "one entity behaves exactly as 0.11.0 does" is a real cross-tree identity over both documents. `pv.total()` passes a single entity's `(watts, why)` through **verbatim** and `checks()` keeps 0.11.0's two f-strings in a `len(...) == 1` branch for exactly that reason. ⚠️ **TEL-11's `--compare` refusal had to change, and that is a finding.** Its rule was *"exactly one side's `set` differs from its own `unset` — that side is the HEAD"*, correct while `release/1.0` predated `pv_entity`. `release/1.0` is 0.11.0 now, **both** sides react, and that refusal fires on every honest run. The discriminator is the roster instead, **measured from the documents** rather than taken from `tree.supports_roster` (both are printed side by side). **MUTATION.md habit 24.** ## Mutation battery — twelve, no survivors Each on a **fresh `cp -a`** of the clean worktree (never reverted — habit 18 / TARIFF-09's method), purged before and checked for a cache after, byte delta from `stat` with the edit confirmed on disk by `cmp`, suites run in the CI image with `checks.yml`'s own command one file at a time under `:ro`. | mutation | bytes | reds | |---|---|---| | N1 `total()` returns the partial sum | +42 B | 6 named + 1 crash | | **N1b ⭐ THE REJECTED DESIGN: partial sum + a contributor count** | −282 B | **10 named + the trace `REFUSED 5/5`** | | N2 absence tested by falsiness (`not w`) | −4 B | 2 named | | N3 `pv_entities` UNIONS with `pv_entity` | −27 B | 3 named | | N4 the de-duplication dropped | −15 B | 1 named | | N5 `sum` → `max` over the roster | **+0 B** | 6 named | | N6 the roster-of-one stops passing `why` through | +13 B | 8 named | | N7 `optional_keys()` back to reading `pv_entity` | −6 B | 1 named + 1 crash | | N8 `self.grid -= self.pv_w` in `read_inputs()` | +106 B | 3 named + trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5) | | N9 the roster caution made a `bad` row (NOT READY) | +1 B | 1 named | | N10 `entity_is_ignored()` always False | +10 B | 2 named | | N11 `pv_entities: []` deleted from `options:` | −18 B | 2 in `test_translations.py` + `11 CASE(S) FAILED` in the probe | ⚠️ **N5 is `+0 B`** — `sum` and `max` are the same three characters, so neither size nor mtime moved and TEST-15's defect is exactly what manufactures a survivor there. Fresh purged copy, `cmp` first. ⚠️ **N1b is the row that matters, and it is not a defect.** It is the *other* defensible design, implemented and run — the point being that the checks and the instrument can **tell the two apart**: ``` REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'), ('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'), …] ``` ⚠️ **N8's 1/5 stays GREEN and that is correct rather than a miss:** with no PV entity configured `pv_w` is `None` and the leak cannot fire — which is what makes 1/5 a claim about the **default** and 2/5–4/5 the claims about the channel. ### The fixture values are the design ⚠️ **Habit 23, in the arithmetic rather than in the plant.** A member contributing **0 W** could go absent without moving the sum of the rest, so a fixture like that cannot tell "published the absence" from "published the partial". `test_pv.py`'s roster is **1200 W + 2.1 kW + 450 W = 3750 W** — three distinct non-zero contributions in two units, separable from max/min/first/last/ mean/count and from each of its own partials (2550 / 1650 / 3300), with that family **computed, printed and asserted non-colliding** as its own precondition (habit 10). Absence is driven at **every position** of the roster, and each position's partial is **derived from the fixture** rather than typed beside it. Part 6 uses a **second** roster (1500 + 4.5 kW + 2000 = 8000 W) sized so every partial would still **move** a loop settling on a 600 W import, and asks habit 23's own question **twice** — shift the meter by 8000 W, and again by 3500 W, the smallest partial any single dead member could produce. ### Two false results this card produced, recorded rather than tidied away 1. The stimulus annotations were first written **inside** each telemetry cell — a fact about the stimulus, identical on both trees, so every roster cell differed for a reason that was not behaviour and the discriminator fired on **both** sides. 2. The `WEST-ZERO` cell had its **true total** labelled as its "partial", so 5/5 refused the **shipped** tree for publishing a number it is required to publish. Both were caught by running the instrument against the shipped tree **before** running it against a mutant. Both are in MUTATION.md habit 24 and `DECISIONS/TEL-12-several-pv-inverters.md` §4. ## Checks **31/31 in the CI image**, `checks.yml`'s exact command, one file at a time, `:ro`, under `/home/homelab/`. No contact with 192.168.2.158 or 192.168.2.221 at any point; no `ha` commands, no `/addons/` writes, no deploys. ## ⚠️ A CHANGELOG entry is wanted and deliberately not written `test_version_gate.py` 11b forbids an `## Unreleased` heading and the bump is PKG's call. The option surface moves **76 keys → 77**. The material an entry needs is `DECISIONS/TEL-12-several-pv-inverters.md`. ## What in the brief turned out to be wrong * *"`tools/tel11_pv_trace.py` … Reuse it rather than writing a second tool."* Reused — but its `--compare` **could not be reused as written**. Its refusal was defined against a baseline that no longer exists, and it refuses the shipped tree on every honest run since TEL-11 merged. Replaced with a discriminator that survives the next merge, and written up as habit 24. * *"If the schema cannot accept both shapes, say so plainly."* It **can** — the `match(.*)?` matcher accepts a string, a list and an integer alike. Rejecting option 1 for "no spelling accepts both" would have been a **fabricated reason**, so §G measures and reports that case even though it passes. The real reason is that the spelling which accepts both validates nothing and cannot be rendered as a repeatable field. * *"habit 23 … a fixture must not sum values at a point where a partial and a full total produce the same published result."* Exactly right, and it landed on the **instrument** rather than on a check: labelling a zero-member cell's true total as its "partial" produced a false red on the shipped tree. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-24 22:14:47 +02:00
0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.

⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.

⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.

THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.

STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.

⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.

Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.

31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

Review 1/7 — the green is this PR's own, traced rather than trusted (CI-10)

Statuses on this forge are keyed by head SHA and one has laundered between PRs before, so I read the run behind the status instead of the badge.

GET /commits/0ef9565…/statuses returns three entries, all pointing at run 829 / job 1954: Waiting to run → In progress → success (Successful in 7m59s). The combined status is success, total_count: 1.

Run 829's own payload:

field value
event pull_request
path checks.yml@refs/pull/116/head
head_sha 0ef9565071597bf40e62fe7bffc75294b565a105
run_attempt 1 (no previous_attempt_url)
pull_requests[0] number 116, head TEL-12 @ 0ef9565, base release/1.0 @ cccb2fa07b5e3c404a059f45665f457e722167db
conclusion success, 22:14:49 → 22:22:49

It is a real execution, not a laundered skipped. Job 1954 has six steps all completed/success, the checkout step names base release/1.0 cccb2fa… and head refs/pull/116/head 0ef9565…, the merge gate prints GRADED: refs/pull/116/head merged into release/1.0 with result 0ef9565 / tree 2f54241733cef52a570370c9493c47ecbdce5d44, and the test step ran 7m53s over 31 test file(s), every one PASS — including test_pv.py, test_mqtt.py, test_translations.py and test_validate.py.

I did not rerun it, deliberately: a rerun replays the stored payload and can post a spurious failure onto a SHA that is already green.

The concrete failure this rules out: merging on a status laundered from a different PR, or on a skipped job that graded nothing — in which case none of the 31 suites below would ever have run against this tree.

origin/TEL-12 on the remote is 0ef9565071597bf40e62fe7bffc75294b565a105, independently confirmed from a fresh clone.

## Review 1/7 — the green is this PR's own, traced rather than trusted (CI-10) Statuses on this forge are keyed by head SHA and one has laundered between PRs before, so I read the run behind the status instead of the badge. `GET /commits/0ef9565…/statuses` returns three entries, all pointing at **run 829 / job 1954**: `Waiting to run` → `In progress` → `success` (`Successful in 7m59s`). The combined status is `success`, `total_count: 1`. Run 829's own payload: | field | value | |---|---| | `event` | `pull_request` | | `path` | `checks.yml@refs/pull/116/head` | | `head_sha` | `0ef9565071597bf40e62fe7bffc75294b565a105` | | `run_attempt` | **1** (no `previous_attempt_url`) | | `pull_requests[0]` | **number 116**, head `TEL-12` @ `0ef9565`, base **`release/1.0` @ `cccb2fa07b5e3c404a059f45665f457e722167db`** | | `conclusion` | `success`, 22:14:49 → 22:22:49 | **It is a real execution, not a laundered `skipped`.** Job 1954 has six steps all `completed/success`, the checkout step names `base release/1.0 cccb2fa…` and `head refs/pull/116/head 0ef9565…`, the merge gate prints `GRADED: refs/pull/116/head merged into release/1.0` with `result 0ef9565` / `tree 2f54241733cef52a570370c9493c47ecbdce5d44`, and the test step ran **7m53s** over **31 test file(s), every one `PASS`** — including `test_pv.py`, `test_mqtt.py`, `test_translations.py` and `test_validate.py`. I did not rerun it, deliberately: a rerun replays the stored payload and can post a spurious failure onto a SHA that is already green. The concrete failure this rules out: merging on a status laundered from a different PR, or on a `skipped` job that graded nothing — in which case none of the 31 suites below would ever have run against this tree. `origin/TEL-12` on the remote is `0ef9565071597bf40e62fe7bffc75294b565a105`, independently confirmed from a fresh clone.
Author
Owner

Review 2/7 — the upgrade path: §G re-run, and the owner's real options blob put through the validator

This is the one that would have hurt the owner, so I re-ran it rather than reading it.

§G, re-run independently

Fresh cp -a of this head, ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, checks.yml-style :ro mount, no contact with any live host:

== all cases passed ==   exit 0

Every line of §G reproduces, and the transcript matches DECISIONS/TEL-12-several-pv-inverters.md §1 word for word — I diffed them. The two load-bearing ones:

PASS  ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string
      an 0.11.0 install has persisted is REFUSED …
      Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS  ...and a shipped `pv_entity: []` DEFAULT does not rescue it …
      Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS  ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME
      Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)

So option 1 really does leave the live add-on unable to start on update, and pv_entities: [] in options: really is load-bearing. Both refusals are asserted on the key's name, from a baseline that is ACCEPTED on the line above — isolated counterfactuals, habit 6 satisfied.

And the shipped shape does the opposite — measured on the owner's actual blob

I did not use predeploy_check.py (its wrapped-JSON defect reads a wrapped document as CLEARED while checking nothing). I unwrapped /home/homelab/live-options-now.json myself — data.options, 75 keys, pv_entity key not present at all — merged it over this manifest's options: with Supervisor's own deepmerge strategy (dict merge / list override) and ran Supervisor's own AppOptions over it:

live blob: 75 keys, pv_entity present=False, pv_entities present=False
  ACCEPTED  the live 0.1x blob exactly as stored        pv_entity='' pv_entities=[]  drift=none
  ACCEPTED  ...plus an 0.11.0 pv_entity STRING          pv_entity='sensor.pv_power' pv_entities=[]  drift=none
  ACCEPTED  ...plus an EMPTY pv_entity                  pv_entity='' pv_entities=[]  drift=none
  ACCEPTED  ...plus BOTH keys set                       pv_entity='sensor.a' pv_entities=[…a,b,c]  drift=none
ALL ACCEPTED

drift=none is a per-key comparison of every one of the owner's 75 stored values against what came out of the validator: not one of their values is rewritten, and pv_entities defaults to []. The option surface is 76 → 77 keys (counted, both trees, through Supervisor's own YAML loader).

p1_phase_import_entities is confirmed as the shipped precedent for the pair — options: [] at line 259, schema: - str at line 994, byte-identical shape to the new key.

Concrete failure this rules out: the update landing on a real GoodWe GW5000S-BP and the add-on refusing to start, with the schema needed to fix it pinned inside the version that will not install. It does not happen. The rollback direction is covered too (§G's last case: a schema without the key drops it and leaves pv_entity standing).

## Review 2/7 — the upgrade path: §G re-run, and the owner's **real** options blob put through the validator This is the one that would have hurt the owner, so I re-ran it rather than reading it. ### §G, re-run independently Fresh `cp -a` of this head, `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`, `checks.yml`-style `:ro` mount, no contact with any live host: ``` == all cases passed == exit 0 ``` Every line of §G reproduces, and the transcript matches `DECISIONS/TEL-12-several-pv-inverters.md` §1 **word for word** — I diffed them. The two load-bearing ones: ``` PASS ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string an 0.11.0 install has persisted is REFUSED … Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller) PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it … Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller) PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller) ``` So option 1 really does leave the live add-on unable to start on update, and `pv_entities: []` in `options:` really is load-bearing. Both refusals are asserted **on the key's name**, from a baseline that is ACCEPTED on the line above — isolated counterfactuals, habit 6 satisfied. ### And the shipped shape does the opposite — measured on the owner's actual blob I did **not** use `predeploy_check.py` (its wrapped-JSON defect reads a wrapped document as CLEARED while checking nothing). I unwrapped `/home/homelab/live-options-now.json` myself — `data.options`, **75 keys**, `pv_entity` key **not present at all** — merged it over this manifest's `options:` with Supervisor's own `deepmerge` strategy (dict merge / list override) and ran Supervisor's own `AppOptions` over it: ``` live blob: 75 keys, pv_entity present=False, pv_entities present=False ACCEPTED the live 0.1x blob exactly as stored pv_entity='' pv_entities=[] drift=none ACCEPTED ...plus an 0.11.0 pv_entity STRING pv_entity='sensor.pv_power' pv_entities=[] drift=none ACCEPTED ...plus an EMPTY pv_entity pv_entity='' pv_entities=[] drift=none ACCEPTED ...plus BOTH keys set pv_entity='sensor.a' pv_entities=[…a,b,c] drift=none ALL ACCEPTED ``` `drift=none` is a per-key comparison of every one of the owner's 75 stored values against what came out of the validator: **not one of their values is rewritten**, and `pv_entities` defaults to `[]`. The option surface is 76 → 77 keys (counted, both trees, through Supervisor's own YAML loader). `p1_phase_import_entities` is confirmed as the shipped precedent for the pair — `options: []` at line 259, `schema: - str` at line 994, byte-identical shape to the new key. **Concrete failure this rules out:** the update landing on a real GoodWe GW5000S-BP and the add-on refusing to start, with the schema needed to fix it pinned inside the version that will not install. It does not happen. The rollback direction is covered too (§G's last case: a schema without the key *drops* it and leaves `pv_entity` standing).
Author
Owner

Review 3/7 — both halves of the "the schema CAN accept both shapes" correction are true

I was briefed that option 1 could be rejected for "no spelling accepts both". The agent corrected that, and the correction holds in both directions — which matters, because a right decision with a fabricated reason is still a defect.

Half one: the permissive spelling exists. §G measures it rather than asserting it. match(.*)? passes SCHEMA_APP_CONFIG as a manifest, and against AppOptions:

string=True list=True integer-417=True

So yes — one key, one spelling, and it swallows a string, a list and the integer 417.

Half two: the stated reason for rejecting it is the true one. The rejection is not "it does not validate both shapes"; it is "it validates nothing, and the Configuration tab renders it as a plain text box". Both halves of that are measured, not claimed. UiOptions(None) over the mutated schema returns:

ui=[{"name": "pv_entity", "optional": true, "type": "string"}]

No multiple: true — so an operator physically cannot add a second inverter in the UI, which is the entire point of the ticket. Against the shipped schema the same call returns:

[{"name": "pv_entity", "optional": true, "type": "string"},
 {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}]

Concrete failure this rules out: shipping the right surface on a reason that does not survive being checked — the PROC-01 shape where the decision is defensible and the evidence for it is invented. Reporting a case that passes, because omitting it would have made the argument false, is the correct call and I would have flagged its absence.

## Review 3/7 — both halves of the "the schema CAN accept both shapes" correction are true I was briefed that option 1 could be rejected for "no spelling accepts both". The agent corrected that, and the correction holds in both directions — which matters, because a right decision with a fabricated reason is still a defect. **Half one: the permissive spelling exists.** §G measures it rather than asserting it. `match(.*)?` passes `SCHEMA_APP_CONFIG` as a manifest, and against `AppOptions`: ``` string=True list=True integer-417=True ``` So yes — one key, one spelling, and it swallows a string, a list *and* the integer `417`. **Half two: the stated reason for rejecting it is the true one.** The rejection is not "it does not validate both shapes"; it is "it validates *nothing*, and the Configuration tab renders it as a plain text box". Both halves of that are measured, not claimed. `UiOptions(None)` over the mutated schema returns: ``` ui=[{"name": "pv_entity", "optional": true, "type": "string"}] ``` No `multiple: true` — so an operator physically cannot add a second inverter in the UI, which is the entire point of the ticket. Against the shipped schema the same call returns: ``` [{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}] ``` **Concrete failure this rules out:** shipping the right surface on a reason that does not survive being checked — the PROC-01 shape where the decision is defensible and the evidence for it is invented. Reporting a case that *passes*, because omitting it would have made the argument false, is the correct call and I would have flagged its absence.
Author
Owner

Review 4/7 — absence-of-one does not read as a smaller real number: the mutants are genuinely red, and the fixture genuinely separates

This is the ticket, so I re-applied the battery myself rather than reading the table. Eight mutations, each on a fresh cp -a of this head (never reverted), __pycache__ purged before and checked absent after, byte delta and on-disk cmp recorded, suites run in the CI image under a :ro mount with checks.yml's own command.

mutation byte delta (on disk) result
N1 total() returns the partial sum +42 B RED, 6 named
N1b — the REJECTED DESIGN: partial sum + contributor count −71 B (pv.py) RED, 6 named
N1b carried through to the status page (so the instrument has a tree to grade rather than a traceback) −71 B + 60 B RED, 9 named
N2 absence tested by falsiness (not w) −4 B RED, 2 named
N3 pv_entities UNIONS with pv_entity −11 B RED, 3 named
N4 de-duplication dropped −15 B RED, 1 named
N5 sum → max +0 B RED, 6 named
N7 optional_keys() back to reading pv_entity +22 B RED, 1 named + KeyError: 'pv'

No survivors. N5 is the +0 B one and it is red with six named FAILs, on a fresh purged copy with cmp confirming the edit landed — so TEST-15's stale-bytecode survivor-manufacture is not in play.

Two notes on my own runs, for honesty: my N1b patch differs in bytes from the recorded −282 B because I wrote it myself, and the naive version crashes checks() (TypeError: unsupported format string passed to NoneType.__format__) — which is itself a red, but it stops the trace tool from producing a document. So I wrote the fuller version that carries design (b) through to the page, which is what the recorded mutant must have been. That one is red with nine named FAILs and is the one I fed to the trace tool (see 5/7).

The fixture's non-collision precondition holds — I checked the arithmetic

1200 W + 2.1 kW + 450 W = 3750.0 W, and the reductions this roster could otherwise be confused with: max 2100, min 450, first 1200, last 450, mean 1250, count 3, first-two 3300, last-two 2550. None equals 3750. The three partials a single dead member would produce are 1650 / 2550 / 3300 — all different from 3750 and from each other, and derived from WATTS_OF rather than typed beside it. Absence is driven at every position, not at one.

And the mirror holds: a member honestly reporting 0 W is summed — 1200 + 0 + 450 = 1650, why == "3 entities". N2 (is None → falsiness) is red on exactly that check, twice. This is where habit 23's trap would have hidden — a zero-contributing member makes "published the absence" and "published the partial" the same number, and this fixture cannot land in that state.

I also exercised entities() directly on the upgrade path that actually matters:

{'pv_entity':'sensor.a','pv_entities':['sensor.a','sensor.b','sensor.c']}
  -> ('sensor.a','sensor.b','sensor.c')   ignored=True

sensor.a appears once. The double-count the replacement rule exists to prevent does not occur even when the operator does the most natural thing.

Concrete failure this rules out: OPT-10 recording a week of history in which two of three inverters silently stand in for three, and then concluding "there was no irradiance" from a number this add-on manufactured. Every way I could think of to produce that number is caught by a named check.

## Review 4/7 — absence-of-one does not read as a smaller real number: the mutants are genuinely red, and the fixture genuinely separates This is the ticket, so I re-applied the battery myself rather than reading the table. Eight mutations, each on a **fresh `cp -a`** of this head (never reverted), `__pycache__` purged before and checked absent after, byte delta and on-disk `cmp` recorded, suites run in the CI image under a `:ro` mount with `checks.yml`'s own command. | mutation | byte delta (on disk) | result | |---|---|---| | N1 `total()` returns the partial sum | **+42 B** | RED, 6 named | | **N1b — the REJECTED DESIGN: partial sum + contributor count** | −71 B (pv.py) | RED, 6 named | | **N1b carried through to the status page** (so the instrument has a tree to grade rather than a traceback) | −71 B + 60 B | **RED, 9 named** | | N2 absence tested by falsiness (`not w`) | −4 B | RED, 2 named | | N3 `pv_entities` UNIONS with `pv_entity` | −11 B | RED, 3 named | | N4 de-duplication dropped | −15 B | RED, 1 named | | **N5 `sum` → `max`** | **+0 B** | **RED, 6 named** | | N7 `optional_keys()` back to reading `pv_entity` | +22 B | RED, 1 named + `KeyError: 'pv'` | **No survivors.** N5 is the +0 B one and it is red with six named FAILs, on a fresh purged copy with `cmp` confirming the edit landed — so TEST-15's stale-bytecode survivor-manufacture is not in play. Two notes on my own runs, for honesty: my N1b patch differs in bytes from the recorded −282 B because I wrote it myself, and the naive version *crashes* `checks()` (`TypeError: unsupported format string passed to NoneType.__format__`) — which is itself a red, but it stops the trace tool from producing a document. So I wrote the fuller version that carries design (b) through to the page, which is what the recorded mutant must have been. That one is red with **nine** named FAILs and is the one I fed to the trace tool (see 5/7). ### The fixture's non-collision precondition holds — I checked the arithmetic 1200 W + 2.1 kW + 450 W = **3750.0 W**, and the reductions this roster could otherwise be confused with: max 2100, min 450, first 1200, last 450, mean 1250, count 3, first-two 3300, last-two 2550. **None equals 3750.** The three partials a single dead member would produce are **1650 / 2550 / 3300** — all different from 3750 *and from each other*, and derived from `WATTS_OF` rather than typed beside it. Absence is driven at **every** position, not at one. And the mirror holds: a member honestly reporting **0 W** is **summed** — `1200 + 0 + 450 = 1650`, `why == "3 entities"`. N2 (`is None` → falsiness) is red on exactly that check, twice. This is where habit 23's trap would have hidden — a zero-contributing member makes "published the absence" and "published the partial" the same number, and this fixture cannot land in that state. I also exercised `entities()` directly on the upgrade path that actually matters: ``` {'pv_entity':'sensor.a','pv_entities':['sensor.a','sensor.b','sensor.c']} -> ('sensor.a','sensor.b','sensor.c') ignored=True ``` `sensor.a` appears **once**. The double-count the replacement rule exists to prevent does not occur even when the operator does the most natural thing. **Concrete failure this rules out:** OPT-10 recording a week of history in which two of three inverters silently stand in for three, and then concluding "there was no irradiance" from a number this add-on manufactured. Every way I could think of to produce that number is caught by a named check.
Author
Owner

Review 5/7 — the control path is untouched: every hash reproduces, and the refusals still refuse

I generated both document sets myself — base tree from git archive cccb2fa with this PR's tel11_pv_trace.py copied in, head tree as merged — and ran --compare. Every hash in DECISIONS/… §3 reproduces exactly, including the one whose stability is the evidence:

control   A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   A one   846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   B one   846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control   A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
control   B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
tree      A fa92ba6a…   tree B 0b39198…   (two different trees, asserted)
OK 1/5 … OK 2/5 … OK 3/5 … OK 4/5 … OK 5/5     EXIT=0

846a10a5b89e… is unmoved on unset and on one, on both trees. Extending rather than replacing the tool was the right call and the reason given is the real one: roster cells live in a separate multi document precisely so those two hashes stay comparable to what TEL-11 recorded.

The refusals still refuse — I re-planted both

The rejected design (N1b, carried through to the page). 4/5 stays green — a partial total reaches no control decision — and 5/5 refuses with the partial sums named:

REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD','2550.0'),
('EXPORT-6K/GARAGE-DEAD','3300.0'), ('EXPORT-6K/WEST-DEAD','1650.0'),
('IMPORT-3K/EAST-DEAD','2550.0'), ('IMPORT-3K/GARAGE-DEAD','3300.0'),
('IMPORT-3K/WEST-DEAD','1650.0')] …                                    EXIT=2

Those are the fixture's three partials, by name, at every position. Byte-for-byte the refusal recorded in the decision record.

The planted leak (N8, self.grid -= self.pv_w in read_inputs()). Red cells, counted:

1/5  OK        (correct: unset means pv_w is None and the leak cannot fire)
2/5  52 FAIL cells
3/5  26 FAIL cells
4/5  10 FAIL cells
5/5  OK
EXIT=1

52 / 26 / 10 — exactly as recorded. So 1/5–4/5 are identities this instrument could have broken, and 5/5's control-of-the-control is doing real work rather than printing OK because its reference drifted.

I also confirmed there is exactly one reading of the options anywhere: grep over app/ finds pv_entity/pv_entities only inside app/pv.py; main.py and mqtt.py both go through pv.entities(). N7 is the mutant that proves it matters — announce off one key, sum off the other, and test_pv.py fails by name and crashes with KeyError: 'pv'.

Concrete failure this rules out: production becoming a term of the control law, which would both be an unrequested behaviour change on a live battery and destroy the measurement OPT-10 is waiting for, because the loop would be reacting to the quantity being measured.

## Review 5/7 — the control path is untouched: every hash reproduces, and the refusals still refuse I generated both document sets myself — base tree from `git archive cccb2fa` with this PR's `tel11_pv_trace.py` copied in, head tree as merged — and ran `--compare`. **Every hash in `DECISIONS/…` §3 reproduces exactly**, including the one whose stability is the evidence: ``` control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30 control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30 tree A fa92ba6a… tree B 0b39198… (two different trees, asserted) OK 1/5 … OK 2/5 … OK 3/5 … OK 4/5 … OK 5/5 EXIT=0 ``` `846a10a5b89e…` is unmoved on `unset` **and** on `one`, on **both** trees. Extending rather than replacing the tool was the right call and the reason given is the real one: roster cells live in a separate `multi` document precisely so those two hashes stay comparable to what TEL-11 recorded. ### The refusals still refuse — I re-planted both **The rejected design (N1b, carried through to the page).** 4/5 stays green — a partial total reaches no control decision — and 5/5 refuses with the partial sums named: ``` REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD','2550.0'), ('EXPORT-6K/GARAGE-DEAD','3300.0'), ('EXPORT-6K/WEST-DEAD','1650.0'), ('IMPORT-3K/EAST-DEAD','2550.0'), ('IMPORT-3K/GARAGE-DEAD','3300.0'), ('IMPORT-3K/WEST-DEAD','1650.0')] … EXIT=2 ``` Those are the fixture's three partials, by name, at every position. Byte-for-byte the refusal recorded in the decision record. **The planted leak (N8, `self.grid -= self.pv_w` in `read_inputs()`).** Red cells, counted: ``` 1/5 OK (correct: unset means pv_w is None and the leak cannot fire) 2/5 52 FAIL cells 3/5 26 FAIL cells 4/5 10 FAIL cells 5/5 OK EXIT=1 ``` **52 / 26 / 10 — exactly as recorded.** So 1/5–4/5 are identities this instrument could have broken, and 5/5's control-of-the-control is doing real work rather than printing OK because its reference drifted. I also confirmed there is exactly **one** reading of the options anywhere: `grep` over `app/` finds `pv_entity`/`pv_entities` only inside `app/pv.py`; `main.py` and `mqtt.py` both go through `pv.entities()`. N7 is the mutant that proves it matters — announce off one key, sum off the other, and `test_pv.py` fails by name *and* crashes with `KeyError: 'pv'`. **Concrete failure this rules out:** production becoming a term of the control law, which would both be an unrequested behaviour change on a live battery and destroy the measurement OPT-10 is waiting for, because the loop would be reacting to the quantity being measured.
Author
Owner

Review 6/7 — habit 24 is a real generalisation, and both instrument defects are accurately written up

Habit 24's central claim is empirical, so I measured it rather than agreeing with it. I took TEL-11's own tel11_pv_trace.py — git show cccb2fa:goodwe_controller/tools/tel11_pv_trace.py — dropped it into both 0.11.0-era trees unmodified, and ran its --compare:

REFUSED: `set` differs from `unset` on ['A', 'B'] side. Exactly one side must react to
`pv_entity` - the HEAD. Neither means the instrument cannot see this card's material at
all, and both means the baseline is not a baseline.
EXIT=2

Confirmed, on an honest run, with the message MUTATION.md quotes. A discriminator written as "the baseline lacks this feature" was true evidence when written and became a guaranteed false refusal the moment TEL-11 merged. That is a fact about a moment, not about a card, and it generalises to every cross-tree instrument in this repo — the question habit 24 tells the next author to ask ("what does this print when the feature it is about has been in the baseline for a year?") is the right question, and the observation that this failed in the loud direction while the same defect in an identity check fails silently and manufactures survivors is the part worth keeping.

The replacement discriminator is measured rather than self-reported, and both are printed side by side so a disagreement is visible:

HEAD is B; baseline is A - its roster cells ARE its unset cells …
supports_roster   A False / B True   (self-reported; the verdict above is MEASURED)

The two instrument defects

Both write-ups are accurate as far as I can check them, and both are the right kind of record — a false result kept rather than tidied away:

  1. partial_w/partial_wire written inside the compared document. A fact about the stimulus, identical on both trees, so every roster cell differed for a non-behavioural reason and the discriminator fired on both sides. The shipped tool has them in a separate top-level partials map, and my run confirms the discriminator now fires on exactly one side.
  2. The WEST-ZERO cell's true total labelled as its "partial". 5/5 would then refuse the shipped tree for publishing 1650 W, which it is required to publish. The shipped 5/5 passes on my run and explicitly says "including the one where a member honestly reports 0 W" — so the fix is real and not a suppression.

The ordering rule is the load-bearing part and it is correctly named: both were found by running the instrument against the shipped tree before any mutant. That is what separates "the instrument is wrong" from "the tree is wrong", and had it been run mutant-first, defect 2 would have looked like a genuine red against the shipped behaviour.

The test_mqtt.py part 8 correction is also measured, not assumed

The comment now says app/mqtt.py imports nothing from the package at module scope, because optional_keys() imports pv inside the function. The comment claims "move this line to module scope and test_mqtt.py part 8 dies". I moved it:

test_mqtt rc=1
ImportError: attempted relative import with no known parent package

True. The function-local import is load-bearing for the mutation harness, not a style choice, and the correction to the comment was necessary — the old wording would have been flatly false about the shipped module.

Concrete failure this rules out: the next card inheriting a cross-tree instrument that either refuses every honest run (a developer chasing an unexplainable failure) or, in the dangerous direction, keeps printing OK because its reference drifted into agreement with it.

## Review 6/7 — habit 24 is a real generalisation, and both instrument defects are accurately written up Habit 24's central claim is empirical, so I measured it rather than agreeing with it. I took **TEL-11's own** `tel11_pv_trace.py` — `git show cccb2fa:goodwe_controller/tools/tel11_pv_trace.py` — dropped it into both 0.11.0-era trees unmodified, and ran its `--compare`: ``` REFUSED: `set` differs from `unset` on ['A', 'B'] side. Exactly one side must react to `pv_entity` - the HEAD. Neither means the instrument cannot see this card's material at all, and both means the baseline is not a baseline. EXIT=2 ``` **Confirmed, on an honest run, with the message MUTATION.md quotes.** A discriminator written as "the baseline lacks this feature" was true evidence when written and became a guaranteed false refusal the moment TEL-11 merged. That is a fact about a *moment*, not about a card, and it generalises to every cross-tree instrument in this repo — the question habit 24 tells the next author to ask ("what does this print when the feature it is about has been in the baseline for a year?") is the right question, and the observation that this failed in the **loud** direction while the same defect in an *identity* check fails silently and manufactures survivors is the part worth keeping. The replacement discriminator is measured rather than self-reported, and both are printed side by side so a disagreement is visible: ``` HEAD is B; baseline is A - its roster cells ARE its unset cells … supports_roster A False / B True (self-reported; the verdict above is MEASURED) ``` ### The two instrument defects Both write-ups are accurate as far as I can check them, and both are the *right* kind of record — a false result kept rather than tidied away: 1. **`partial_w`/`partial_wire` written inside the compared document.** A fact about the stimulus, identical on both trees, so every roster cell differed for a non-behavioural reason and the discriminator fired on both sides. The shipped tool has them in a separate top-level `partials` map, and my run confirms the discriminator now fires on exactly one side. 2. **The `WEST-ZERO` cell's true total labelled as its "partial".** 5/5 would then refuse the shipped tree for publishing 1650 W, which it is *required* to publish. The shipped 5/5 passes on my run and explicitly says "including the one where a member honestly reports 0 W" — so the fix is real and not a suppression. The ordering rule is the load-bearing part and it is correctly named: **both were found by running the instrument against the shipped tree before any mutant.** That is what separates "the instrument is wrong" from "the tree is wrong", and had it been run mutant-first, defect 2 would have looked like a genuine red against the shipped behaviour. ### The `test_mqtt.py` part 8 correction is also measured, not assumed The comment now says `app/mqtt.py` imports nothing from the package **at module scope**, because `optional_keys()` imports `pv` inside the function. The comment claims "move this line to module scope and test_mqtt.py part 8 dies". I moved it: ``` test_mqtt rc=1 ImportError: attempted relative import with no known parent package ``` True. The function-local import is load-bearing for the mutation harness, not a style choice, and the correction to the comment was necessary — the old wording would have been flatly false about the shipped module. **Concrete failure this rules out:** the next card inheriting a cross-tree instrument that either refuses every honest run (a developer chasing an unexplainable failure) or, in the dangerous direction, keeps printing OK because its reference drifted into agreement with it.
Author
Owner

Review 7/7 — replacement is the right call; one non-blocking observation

pv_entities replaces rather than unions — implemented, stated, and I agree with the trade

Verified in the code and by driving it:

{'pv_entity':'sensor.a','pv_entities':['sensor.a','sensor.b','sensor.c']} -> 3 entities, ignored=True
{'pv_entity':'sensor.a','pv_entities':['sensor.b']}                      -> ('sensor.b',),  ignored=True
{'pv_entity':'sensor.a','pv_entities':['  ','']}                         -> ('sensor.a',),  ignored=False
{'pv_entities':['sensor.b','sensor.b']}                                  -> ('sensor.b',)

The rule and the sentence reporting it are one reading of the options — entities() and entity_is_ignored() sit beside each other and strip identically, so the whitespace-only case cannot produce a roster the caution disagrees with. The caution names which key won and how many entities are summed, and it is a warn and never a bad row.

On replacement versus refusing to start with both set: refusal is wrong here and the stated reason is the correct one — a telemetry key that can stop a battery site from starting is observability with a foot in the control path, which is this card's own first constraint. It would also be the worse failure mode in practice: the operator who leaves pv_entity standing is mid-configuration on a running house, and the punishment for a cosmetic mistake would be a dead controller. Union is the genuinely dangerous option (silent double-count of inverter A, in the one channel that has to be believed cold a week later), and replacement plus a named caution is what CFG-04 already shipped for inverters:. Nothing new was invented, which is the right answer for a hotfix-shaped card.

Non-blocking observation: _FAMILY is nine typed literals beside a computed fixture

test_pv.py's HOLES is derived from WATTS_OF — habit 12, correctly — but _FAMILY next to it is hand-typed, and one entry does not name its own value:

_FAMILY = {"max": 2100.0, …, "product/1000": 1134.0}

Taken in the watts the other eight entries use, the product is 1200 × 2100 × 450 = 1.134e9, so /1000 is 1 134 000, not 1134. 1134.0 is the product of the raw state values (1200 × 2.1 × 450) over 1000 — i.e. the one entry that mixes units with the rest of the family.

This does not weaken anything: the check is all(v != 3750.0), and both 1134 and 1 134 000 differ from 3750, so the precondition's verdict is unaffected and the eight entries that do the real separating work are all correct. I state it only because it is the visible instance of the general point — a typed literal beside a fixture can already have drifted from what it names, and if someone edits the three fixture values later, _FAMILY will go stale silently while still printing PASS. Computing it (or dropping the one mixed-unit entry) would make it match HOLES' own discipline.

Same size of nit, same verdict: the both-keys caution reads "the 1 entities in the list are what is summed" when pv_entities holds exactly one row. Cosmetic.

Neither blocks. Nothing else I found is wrong.

## Review 7/7 — replacement is the right call; one non-blocking observation ### `pv_entities` replaces rather than unions — implemented, stated, and I agree with the trade Verified in the code and by driving it: ``` {'pv_entity':'sensor.a','pv_entities':['sensor.a','sensor.b','sensor.c']} -> 3 entities, ignored=True {'pv_entity':'sensor.a','pv_entities':['sensor.b']} -> ('sensor.b',), ignored=True {'pv_entity':'sensor.a','pv_entities':[' ','']} -> ('sensor.a',), ignored=False {'pv_entities':['sensor.b','sensor.b']} -> ('sensor.b',) ``` The rule and the sentence reporting it are one reading of the options — `entities()` and `entity_is_ignored()` sit beside each other and strip identically, so the whitespace-only case cannot produce a roster the caution disagrees with. The caution names which key won and how many entities are summed, and it is a `warn` and never a `bad` row. **On replacement versus refusing to start with both set:** refusal is wrong here and the stated reason is the correct one — a *telemetry* key that can stop a battery site from starting is observability with a foot in the control path, which is this card's own first constraint. It would also be the worse failure mode in practice: the operator who leaves `pv_entity` standing is mid-configuration on a running house, and the punishment for a cosmetic mistake would be a dead controller. Union is the genuinely dangerous option (silent double-count of inverter A, in the one channel that has to be believed cold a week later), and replacement plus a named caution is what CFG-04 already shipped for `inverters:`. Nothing new was invented, which is the right answer for a hotfix-shaped card. ### Non-blocking observation: `_FAMILY` is nine typed literals beside a computed fixture `test_pv.py`'s `HOLES` is derived from `WATTS_OF` — habit 12, correctly — but `_FAMILY` next to it is hand-typed, and one entry does not name its own value: ```python _FAMILY = {"max": 2100.0, …, "product/1000": 1134.0} ``` Taken in the watts the other eight entries use, the product is `1200 × 2100 × 450 = 1.134e9`, so `/1000` is **1 134 000**, not 1134. `1134.0` is the product of the *raw state values* (`1200 × 2.1 × 450`) over 1000 — i.e. the one entry that mixes units with the rest of the family. This does **not** weaken anything: the check is `all(v != 3750.0)`, and both 1134 and 1 134 000 differ from 3750, so the precondition's verdict is unaffected and the eight entries that do the real separating work are all correct. I state it only because it is the visible instance of the general point — a typed literal beside a fixture can already have drifted from what it names, and if someone edits the three fixture values later, `_FAMILY` will go stale silently while still printing PASS. Computing it (or dropping the one mixed-unit entry) would make it match `HOLES`' own discipline. Same size of nit, same verdict: the both-keys caution reads "the 1 entities in the list are what is summed" when `pv_entities` holds exactly one row. Cosmetic. **Neither blocks.** Nothing else I found is wrong.
admin merged commit f863ecaa8c into release/1.0 2026-09-24 22:41:20 +02:00
Author
Owner

Merged

release/1.0 is now f863ecaa8c81b635d8c0b73eceebfd22877c7860, confirmed from the remote after a fresh fetch. master is untouched at 78acb55, and no branch protection was changed.

The merged tree is 2f54241733cef52a570370c9493c47ecbdce5d44 — the same tree CI graded in run 829 / job 1954, whose merge step printed exactly that hash. So the green applies to precisely what landed, not to an ancestor of it.

⚠️ This does not reach the owner yet. config.yaml stays at 0.11.0, so tools/predeploy_check.py reports a NO-OP until PKG cuts a version, and the CHANGELOG entry is deliberately unwritten (11b forbids an ## Unreleased heading; the bump is PKG's call) with DECISIONS/TEL-12-several-pv-inverters.md as its material. OPT-10 is unblocked at the code level; it is not unblocked on the live install until a version bump ships.

## Merged `release/1.0` is now **`f863ecaa8c81b635d8c0b73eceebfd22877c7860`**, confirmed from the remote after a fresh fetch. `master` is untouched at `78acb55`, and no branch protection was changed. The merged tree is `2f54241733cef52a570370c9493c47ecbdce5d44` — **the same tree CI graded** in run 829 / job 1954, whose merge step printed exactly that hash. So the green applies to precisely what landed, not to an ancestor of it. ⚠️ **This does not reach the owner yet.** `config.yaml` stays at `0.11.0`, so `tools/predeploy_check.py` reports a NO-OP until PKG cuts a version, and the CHANGELOG entry is deliberately unwritten (11b forbids an `## Unreleased` heading; the bump is PKG's call) with `DECISIONS/TEL-12-several-pv-inverters.md` as its material. OPT-10 is unblocked at the code level; it is not unblocked on the live install until a version bump ships.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#116