TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s

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
This commit is contained in:
2026-09-24 22:13:25 +02:00
co-authored by Claude Opus 5
parent cccb2fa07b
commit 0ef9565071
13 changed files with 1893 additions and 125 deletions
+66
View File
@@ -174,6 +174,47 @@ options:
# is worse than a refusal an operator can read.
pv_entity: ""
# TEL-12 - THE SAME CHANNEL WHEN THE ROOF HAS SEVERAL INVERTERS, WHICH IS THE
# ORDINARY CASE AND NOT AN EDGE ONE. A multi-string or multi-orientation roof
# routinely carries two or three PV inverters, each publishing its own power
# sensor; 0.11.0 shipped `pv_entity` as a single string and the reference
# site's owner reported within minutes that they could configure one of
# theirs. List them all here and the row carries the SITE total.
#
# ⚠️ DECLARED, THIS REPLACES `pv_entity` - it does not extend it. Set both and
# ONLY this list is read, and the status page says so. That is CFG-04's rule
# for `inverters:` applied to the upgrade path this key exists for: the
# install being upgraded HAS `pv_entity` set, and an operator who adds all
# their inverters to this list while the old key still names one of them would
# DOUBLE that inverter under a union rule. Clear `pv_entity` when you fill
# this in.
#
# ⚠️ AND IF ANY ONE OF THEM IS NOT REPORTING, THERE IS NO SITE TOTAL - the row
# publishes `unknown` and the recorder records a GAP. It does NOT publish the
# sum of the ones that answered: two of three inverters add up to a smaller
# number that is indistinguishable from a real one, and a week later that
# reads as "there was less sun than there was" - the exact conclusion this
# channel exists to TEST rather than to manufacture. The status page names
# which entity is missing and why, and lists what the others reported, because
# a human reading it live wants the diagnostic; the recorded number does not,
# because nobody re-derives a week of history. See
# DECISIONS/TEL-12-several-pv-inverters.md.
#
# ⚠️ THE FIX IS NOT A TEMPLATE SENSOR THAT SUMS THEM. You may legitimately
# prefer one, but a template sensor summing an `unavailable` source yields the
# partial total silently - the same defect one layer down, in a place this
# add-on cannot see or report.
#
# pv_entities:
# - sensor.pv_east_power
# - sensor.pv_west_power
# - sensor.pv_garage_power
#
# Each entity's OWN `unit_of_measurement` is read, so a kW inverter beside a W
# one sums correctly. Empty is the shipped default and means "use `pv_entity`,
# which is also empty" - i.e. no PV channel at all, which is every install.
pv_entities: []
# ==========================================================================
# METER - where the grid reading comes from
# ==========================================================================
@@ -906,6 +947,31 @@ schema:
# because nothing else reads it.
pv_entity: str?
# TEL-12. ⚠️ A SECOND KEY RATHER THAN A LIST SHAPE ON `pv_entity`, AND THAT IS
# A MEASUREMENT AND NOT A PREFERENCE. `pv_entity` shipped in 0.11.0 as `str?`
# and the reference site has a string persisted in it. Declared as a list,
# Supervisor 2026.08.0 answers that stored string with
#
# Invalid list for option 'pv_entity' in GoodWe RS485 Controller
#
# and `deepmerge`'s list strategy is `override`, so a shipped `[]` default does
# NOT rescue it - the operator's string stands and is refused. That is CFG-04
# §B's trap in the other direction: an add-on that will not start, and the
# schema needed to fix it pinned to the version that cannot be installed. The
# only single-key spelling Supervisor accepts for BOTH shapes is `match(.*)?`,
# which validates nothing at all (it admits a number, a dict, any list of
# anything) and renders as a plain text box. Measured, both of them, by
# `tools/supervisor_shape_probe.py` §G - the transcript is in
# DECISIONS/TEL-12-several-pv-inverters.md §1.
#
# ⚠️ AND `pv_entities: []` IN THE `options:` BLOCK IS LOAD-BEARING, exactly as
# `inverters: []` is. §G measures the same isolated counterfactual: the
# shipped defaults MINUS only this key are refused with `Missing option
# 'pv_entities' in root`, which is every options blob written before 0.12.
# `p1_phase_import_entities` is the shipped precedent for the pair.
pv_entities:
- str
# ==========================================================================
# METER - where the grid reading comes from
# ==========================================================================