Files
goodwe-addon/goodwe_controller/DECISIONS/TEL-12-several-pv-inverters.md
T
glenn schrooyenandClaude Opus 5.5 571b884315
checks / unit (pull_request) Successful in 8m48s
infra-pin / fresh (pull_request) Successful in 1s
UX-08 phase 2b review: keep over-rating evidence, pin latch clearing, plainer rows
- During a bound episode an earlier over-rating count stays on the page (amber,
  under its own title); test_clamp pins it.
- test_messages pins that every latch row says how it clears.
- pv_entities description no longer claims the page names the entity (en, nl);
  allowlist hash updated for that option-desc line.
- Plainer texts: multi-inverter direction banner, "fine now" after an episode,
  and the e-stop, peak-forecast and peak-guard-waiting rows move to messages.py.
- TEL-12 DECISIONS points to UX-08 item 3 for the retired row rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:10:25 +02:00

23 KiB
Raw Blame History

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.gathered 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.