# TEL-12 — several PV inverters, and why a partial total is worse than none TEL-11 shipped `pv_entity` as a single `str?` in 0.11.0. 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 not a property of one house. A multi-string or multi-orientation roof routinely carries two or three inverters, each publishing its own power sensor into Home Assistant, and the add-on is sold to clients. So the channel had to become a roster. **OPT-10 is blocked on this and cannot start recording until it lands.** Which sets the bar: the number this channel publishes has to be one an attribution can believe, or the week of history OPT-10 is waiting for is worse than no history. --- ## 1. The surface: `pv_entities` beside `pv_entity`, and the measurement that decided it Two candidates, and they differ on exactly one question nobody in this repository could answer by reading: **does Supervisor accept a stored STRING for a key whose schema has become a LIST?** 0.11.0 shipped `pv_entity: str?`; the live install *has* a string persisted in it; and an upgrade that refuses that string leaves an add-on which will not start — CFG-04 §B's trap, in the other direction, with the schema needed to fix it pinned to the version that cannot be installed. It was measured, against the release image, by a new **§G** of `tools/supervisor_shape_probe.py` — the instrument CFG-04 built for exactly this kind of question: ``` docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" --entrypoint python3 \ ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \ /probe/supervisor_shape_probe.py ``` ``` == G. TEL-12: the SHAPE of a several-inverter PV option == PASS the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED - the baseline every refusal below is varied from pv_entity='sensor.pv_power' pv_entities=[] 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, and the refusal names the key - so that shape would leave the live add-on unable to start on upgrade Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller) PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's list strategy is `override`, so the operator's string survives the merge and is refused anyway Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller) PASS ...(the list shape itself is fine - it is only the STORED STRING it refuses, which is what makes this a migration and not a schema question) a list value validates PASS ⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, a list passes, and so does the integer 417; and the Configuration tab renders it as a plain text box rather than a repeatable field, so an operator cannot enter a second inverter in the UI at all string=True list=True integer-417=True ui=[{"name": "pv_entity", "optional": true, "type": "string"}] PASS ⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no `pv_entities` key at all, because it did not exist when that blob was written - validates against THIS manifest, with the new key defaulted to the empty list. The live install upgrades without touching its options PASS ...and a three-entity roster validates, per-entry, with both keys set - which the add-on resolves by REPLACEMENT and says so on its status page ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"] PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the `pv_entities: []` default is load-bearing, exactly as `inverters: []` is: Supervisor treats a nested list as required unless its element is an OPTIONAL scalar, and `- str` is not one Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller) PASS ...and the Configuration tab renders it as a REPEATABLE string field (`multiple: true`), so an operator adds their second and third inverter in the UI rather than in YAML [{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}] PASS a DOWNGRADE to a schema without `pv_entities` drops the key with a warning rather than refusing, and leaves `pv_entity` standing - so rolling back to 0.11.0 returns the install to its single entity rather than bricking it == all cases passed == ``` | Claim | Evidence | |---|---| | **Option 1 — `pv_entity` becomes a list — cannot ship** | §G: `Invalid list for option 'pv_entity'` on the string an 0.11.0 install has stored | | …and a `[]` default does not rescue it | §G: `_OPTIONS_MERGER`'s list strategy is `override`, so the operator's string survives the merge and is refused anyway | | …and it is not a *schema* problem | §G: a list **value** validates fine against a list schema. It is a **migration**, and a migration is a bigger ticket than this | | **One key CAN carry both shapes** — `match(.*)?` | §G, and reported even though it *passes*, because "no spelling accepts both" would have been a **false reason** to reject option 1 | | …and it loses anyway, for a measured reason | §G: it validates nothing at all — a string, a list, and the integer `417` all pass — and the Configuration tab renders it as a plain text box, so an operator cannot add a second inverter in the UI | | **Option 2 — `pv_entities` beside `pv_entity` — ships** | §G: the 0.11.0 blob validates untouched, the new key defaults to `[]`, three entities validate, the UI renders `multiple: true` | | `pv_entities: []` in `options:` is load-bearing | §G: the shipped defaults **minus only that key** are refused with `Missing option 'pv_entities' in root` — CFG-04 §B's isolated counterfactual, for this key | | A downgrade is not the trap in reverse | §G: a schema without the key **drops** it and leaves `pv_entity` standing | `p1_phase_import_entities` (`options: []` + `schema: - str`) is the shipped precedent for the pair; nothing new was invented. **So the surface is uglier than option 1 and that is the correct trade.** The cleanest surface that breaks the one install this card exists to fix is not the cleanest surface. ### And `pv_entities` REPLACES `pv_entity` rather than extending it CFG-04's rule for `inverters:` ("declared, it REPLACES those six keys"), applied to the only upgrade path that matters. The install being upgraded **has** `pv_entity` set, to inverter A. Opening the Configuration tab, the natural action is to put *all* the inverters in the new repeatable field — and under a union rule that **double-counts A** unless the operator also remembers to clear the old key. A doubled total is the same defect as a partial one pointing the other way, in the one channel that exists to be believed a week later. Under replacement the effective roster is exactly what one key says, and no arithmetic depends on a de-duplication rule the operator has to know about. It de-duplicates *within* the list (a site cannot have two sensors with one entity_id, so a repeated id is a typo that would double that inverter), and `Controller.checks()` prints a **caution** whenever both keys are set saying which one won — a replacement nobody is told about is the "one of which silently wins" `config.yaml` refuses elsewhere. ⚠️ It is a **caution and never a refusal to start**. `validate.py` still does not mention either key. A telemetry channel that could stop a battery site from starting would be observability with a foot in the control path. --- ## 2. ⚠️⚠️ 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.** Fed into OPT-10's attribution it reads as *"there was less PV than there was"* — which is the "no irradiance" conclusion the channel exists to **test** rather than to manufacture. Two designs were genuinely available: **(a) publish the total as absent** — `pv.total()` answers `None`, the topic carries HA's own `None`, the entity goes to `unknown`, the recorder stores a gap. **(b) publish the partial sum alongside a count of how many contributed.** **(a) shipped.** It wins on the one question that matters, which is *who reads the number and when*: * The **MQTT row** is read back **cold**, a week later, by OPT-10 and by anything that plots `sensor.goodwe_pv_power` over that week. A partial sum there is a smaller number indistinguishable from a real one, and the count that would have qualified it lives in a **second entity with its own history** — so believing the total requires joining two recorder series by timestamp to find out which samples to distrust. Nobody re-derives that. And it is worse than a gap in exactly the direction that matters: **a gap makes the attribution say "I do not know"; a partial makes it say something false.** * 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 did report, with its watts, and every one that did not, with its reason, and states plainly that there is no site total. Different reader, different instant, different rule. A count published beside the total 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)`. And this **extends** TEL-11's rule rather than weakening it. `mqtt.py`'s `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, which matters just as much A member honestly reporting **0 W** is **summed**, not treated as absent. The test is `watts is None`, never falsiness. Shading, a tripped string and nightfall are all real zeros, and a roster that blanked the site whenever one member read zero would report an absence **on every clear evening** and defeat the channel from the other end. ### ⚠️ And the answer is NOT "document a template sensor that adds them up" An operator may legitimately prefer one, and `pv_entity` will happily point at it. Shipping it as *the* answer pushes the absence problem onto them unseen: a template sensor summing an `unavailable` source silently yields the partial total — the same defect one layer down, in a place this add-on cannot see or report. DOCS.md says so in those terms. ### The three published states, side by side | what happened | frame | `goodwe_ctl/pv` | status page | |---|---|---|---| | no roster at all | no `pv` key | no topic, no entity | no row | | 1200 / 2100 / 450 W, all three | `pv: 3750.0` | `3750.0` | `PV 3 750 W` + one ok row naming all three | | all three answer, one of them `0 W` | `pv: 1650.0` | `1650.0` | `PV 1 650 W` | | one of the three `unavailable` | `pv: None` | `None` → `unknown`, recorder **gap** | `PV —` + a caution naming the missing entity, its reason, and what the others reported | | all three gone | `pv: None` | as above | as above | --- ## 3. Still not a control input Unchanged, and re-proved at the new roster size. `tools/tel11_pv_trace.py` was **extended rather than replaced** — the file name still says TEL-11 because `DECISIONS/TEL-11-pv-production.md` §5 and `test_pv.py`'s mutation table quote it, and a renamed instrument makes both references dangle for no gain. It now produces **three** document sets per tree — `unset`, `one`, `multi` — and grades five claims. The roster cells live in their own document precisely so `unset` and `one` keep the `control` hashes TEL-11 recorded; appending cells to an existing document would have moved a hash whose stability is itself the evidence. ``` $ python3 tools/tel11_pv_trace.py --compare base.json head.json HEAD is B; baseline is A - its roster cells ARE its unset cells, which is what a tree that has never heard of `pv_entities` does with them. supports_roster A False / B True (self-reported; the verdict above is MEASURED) tree A fa92ba6a6fc6d49c0f87a494e03b530a8575212a0b2c3046629cb400ef2ae0d4 tree B 0b39198015b6a5a3f7538d4157d77ecadc89311daef18fce883762d94d8351eb control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30 control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30 telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164 telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164 telemetry A one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f telemetry B one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f telemetry A multi d269168ffece375027c2c18ea33631a961ff92775781884a0592d6a37d78845c telemetry B multi 550a49a73f39267ba28b1e78cd12b8f5c71ef7269bbbd2e12112696be0fef1ff OK 1/5 UNSET: control AND telemetry are byte-identical to the baseline tree. … OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to 0.11.0's single-entity path - the same commands, the same payloads, the same status rows, the same announcement. OK 3/5 CONTROL, ONE ENTITY: the control document is byte-identical with the channel switched on - PV reaches no decision. OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control document equals its grid world's `unset` one - 30 cells over 5 grid worlds, covering three healthy entities, one dead at EVERY position, all three dead and one honestly reporting 0 W. Neither the total nor any PARTIAL total reaches a decision, and the document is byte-identical to the BASELINE tree's for the same cells. OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES move under a roster (30 stimulus cells differ from their grid world's unset telemetry), so 1/5-4/5 are identities this instrument could have broken. AND in no cell with a dead member does the PV row carry the partial sum of the survivors, while every cell whose members all ANSWER publishes its total - including the one where a member honestly reports 0 W. $ echo $? 0 ``` **The control hash is unmoved: `846a10a5b89e…` on `unset` and on `one`, on both trees.** The roster control document `2aafbfedfb9c…` is byte-identical between the two trees, which is the strongest available form of the claim — the baseline runs those cells with a key it has never heard of, so its control document for them *is* 0.11.0's. ⚠️ **2/5 is a claim TEL-11 could not make.** TEL-11's baseline had no channel at all to be identical to; TEL-12's baseline 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 for exactly this reason, and `Controller.checks()` keeps 0.11.0's two f-strings in a `len(...) == 1` branch rather than generalising them. ### ⚠️ TEL-11's `--compare` refusal had to change, and that is a finding TEL-11's rule was *"exactly ONE side's `set` document may differ from its own `unset` — that side is the HEAD"*. It was correct evidence when written, because `release/1.0` predated `pv_entity` entirely. `release/1.0` is 0.11.0 now, **both** sides react to `pv_entity`, and that refusal fires on **every honest run**. The discriminator is the roster instead: only the HEAD has heard of `pv_entities`, so only the HEAD's `multi` document fails to reduce to its own `unset` one. It is asserted from the **documents**, not from `tree.supports_roster` — a self-report is the tree describing itself, whereas "this side's behaviour did not move when `pv_entities` was set" is the tree being measured. `supports_roster` is printed beside the verdict so a disagreement between the two is visible. See MUTATION.md habit 24. --- ## 4. Mutation battery Twelve mutations, each on a **fresh `cp -a`** of the clean worktree (never reverted — habit 18 / TARIFF-09's method), `__pycache__` purged before the run and the tree checked for one afterwards, byte delta from `stat` and the edit confirmed on disk with `cmp`, suites run in the CI image with `checks.yml`'s own command one file at a time under a `:ro` mount. | mutation | bytes | reds | |---|---|---| | N1 `total()` returns the partial sum, not an absence | +42 B | 6 named in `test_pv.py` + 1 crash | | **N1b ⭐ THE REJECTED DESIGN: the partial sum with 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 + the 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 named in `test_translations.py` + `11 CASE(S) FAILED` in the probe | **No survivors.** ⚠️ **N5 is `+0 B` — the risky delta.** `sum` and `max` are the same three characters, so neither the file size nor its mtime moved, and TEST-15's stale-bytecode defect is exactly what manufactures a survivor there. The run was on a fresh purged copy and `cmp` confirmed the edit on disk before it started. ⚠️ **N1b is the row that matters, and it is not a defect.** It is the *other* defensible design, implemented and run. The point is that the checks and the instrument can **tell the two apart** — an unexamined partial sum is what the card forbids, so a check that could not see one would be decoration: ``` 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')] … - a smaller number indistinguishable from a real one, which is the defect this card exists to prevent. ``` ⚠️ **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 10 and habit 23) `test_pv.py` carries **two** rosters over the same three entities, because the two claims need different magnitudes and one fixture cannot serve both. * **Parts 8–10: 1200 W + 2.1 kW + 450 W = 3750 W.** The sum is separable from max (2100), min (450), first (1200), last (450), mean (1250), count (3) and every partial of itself (2550 / 1650 / 3300) — the family is computed, printed and asserted non-colliding as its own precondition. One member is in **kW** so "correct against their own units" is exercised. **Not one member is zero**: a member contributing 0 W could go absent without moving the sum of the rest, and a fixture like that cannot tell "published the absence" from "published the partial sum" — habit 23 with a coinciding value in the *fixture* rather than in the plant, which is precisely the trap the brief named. Absence is driven at **every position** of the roster, not at one (habit 10's fixture-as-a-SET), and each position's partial is **derived from the fixture** rather than typed beside it. * **Part 6: 1500 W + 4.5 kW + 2000 W = 8000 W.** Sized so every *partial* (6500 / 3500 / 6000) would still **move** a loop settling on a 600 W import. Habit 23's own question is asked twice — shift the meter by 8000 W, and again by 3500 W, the smallest partial any single dead member could produce — because a leak of the partial is a different mutation leaking a smaller number. ### Two false results this card produced, and what they were Both were in the **instrument**, and both are recorded rather than tidied away. 1. `partial_w`/`partial_wire` were first written **inside** each telemetry cell. They are a fact about the *stimulus*, produced by the tool and identical on both trees — so every roster cell differed from its unset reference for a reason that was not behaviour, and the HEAD/baseline discriminator fired on *both* sides. They live in a separate top-level `partials` map now. 2. The `WEST-ZERO` cell (all three answer, one honestly reporting 0 W) had its **true total** labelled as its "partial". 5/5 then refused the *shipped* tree for publishing 1650 W, which it is required to publish. A cell with no dead member has no partial to leak, and `never` is `None` there. --- ## 5. What none of this can see * **Home Assistant itself.** That `None` on the state topic renders as state `unknown` and that the recorder keeps a gap is read out of HA's source and quoted in `app/mqtt.py`; whether the long-term statistics table then interpolates across the gap is HA's behaviour, and OPT-10 will be reading it. * **Whether the operator's sensors are telling the truth.** A sensor wired backwards reads −4 kW at noon and is summed as −4 kW. There is deliberately no `pv_invert` — §2 of the TEL-11 record is the argument, and it holds at a roster. * **A roster larger than three.** Three is the smallest size at which the sum separates from the reduction family *and* absence can be driven at every position. Nothing here says what twenty entities do to a cycle's latency — the read is `asyncio.gather`ed across the roster for exactly that reason, so a slow sensor costs what one costs, but that is an argument rather than a measurement. * **A PV roster combined with a two-inverter battery roster.** No fixture crosses CFG-04's `inverters:` with `pv_entities`. * **The real MQTT transport, and wall-clock behaviour.** As TEL-11. --- ## 6. Not deployable as merged `config.yaml` stays at `0.11.0`, so `tools/predeploy_check.py` reports a NO-OP until PKG cuts a version. A CHANGELOG entry is **wanted** and was deliberately not written: `test_version_gate.py` 11b forbids an `## Unreleased` heading and the bump is PKG's call. The material an entry needs is this file. The option surface moves **76 keys → 77**, one new optional key, `pv_entities`, empty on every install that does not set it. **Superseded in part (UX-08, 2026-09-27):** the PV status rows no longer read 0.11.0's sentences at one entity. The owner ruled out entity ids on status rows, so 2/5 now holds for the control document only. See `UX-08-short-notifications.md`, phase 2b item 3.