TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
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:
+145
-3
@@ -1,12 +1,28 @@
|
||||
"""TEL-11 - the site's PV production, read from a Home Assistant entity.
|
||||
"""TEL-11/TEL-12 - the site's PV production, read from Home Assistant entities.
|
||||
|
||||
WHY THIS IS AN ENTITY AND NOT A REGISTER. This add-on's RS485 bus carries the
|
||||
BATTERY inverter it drives. A household's PV inverter is, on nearly every site,
|
||||
a different box from a different vendor that reports into Home Assistant
|
||||
through its own integration - so the only place a product can read production
|
||||
for an arbitrary install is an HA entity. `pv_entity` therefore takes whatever
|
||||
for an arbitrary install is an HA entity. `pv_entities` therefore takes whatever
|
||||
`sensor.*` the site's own PV integration provides.
|
||||
|
||||
⚠️ AND THERE IS USUALLY MORE THAN ONE OF THEM, WHICH IS TEL-12. TEL-11 shipped
|
||||
`pv_entity` as a single string and the owner of the reference site reported
|
||||
within minutes of 0.11.0 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. `entities()` below is the roster and `total()`
|
||||
is the site figure over it.
|
||||
|
||||
⚠️ AND A PARTIAL TOTAL IS WORSE THAN NO TOTAL, WHICH IS THE WHOLE OF TEL-12.
|
||||
Three inverters configured, one `unavailable`, and the sum of the other two is A
|
||||
SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed into OPT-10's attribution
|
||||
that reads as "there was less PV than there was" - which is the "no irradiance"
|
||||
conclusion the card exists to TEST rather than to manufacture. So `total()`
|
||||
answers `None` the moment ANY configured entity is absent. See the note on
|
||||
`total()` for the alternative that was weighed and why it lost.
|
||||
|
||||
WHY THE CHANNEL EXISTS AT ALL. OPT-10 measured that 20.5 kWh of surplus left the
|
||||
site in 16 days while the pack had room, 12.6 kWh of it with the battery not
|
||||
charging at all - and could not say WHY, because PV production is recorded
|
||||
@@ -28,7 +44,11 @@ returns `None` with a REASON, never `0.0`. A PV channel that reported 0 W for a
|
||||
dead sensor would manufacture exactly the "there was no surplus to store"
|
||||
conclusion the card exists to TEST - and it would do it silently, in a week of
|
||||
recorder history nobody would re-derive. `app/hass.py`'s own note on `state()`
|
||||
is the same rule one layer down.
|
||||
is the same rule one layer down. ⚠️ TEL-12 EXTENDS THAT RULE RATHER THAN
|
||||
WEAKENING IT: at a roster of three, "one of them is absent" is an absence of the
|
||||
SITE figure, and it publishes as an absence. A partial sum is a second way of
|
||||
reporting 0-where-there-was-production, one layer up, and it is refused for the
|
||||
same reason.
|
||||
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
|
||||
this add-on already ships rather than assumed:
|
||||
@@ -115,3 +135,125 @@ def watts(state: dict | None) -> tuple:
|
||||
return None, (f"unit {unit!r} is not one of "
|
||||
f"{', '.join(WATTS_PER)} - it cannot be read in watts")
|
||||
return value * WATTS_PER[unit], unit
|
||||
|
||||
|
||||
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
|
||||
|
||||
def entities(opts: dict) -> tuple:
|
||||
"""Every PV entity this configuration names, in configured order. TEL-12.
|
||||
|
||||
⚠️ ONE ANSWER TO "WHICH ENTITIES ARE THE PV CHANNEL", and it is the answer
|
||||
BOTH sides read. `Controller.pv_roster` is this call and
|
||||
`mqtt.optional_keys()` is `bool()` of this call, so the thing that decides
|
||||
whether the row is ANNOUNCED, the thing that decides whether it is READ and
|
||||
the thing that decides HOW MANY entities are summed cannot disagree. TEL-11
|
||||
already had that discipline for one entity and spelled the predicate TWICE
|
||||
- `str(opts.get("pv_entity") or "").strip()` in `main.py` and again in
|
||||
`mqtt.py`. Two spellings of one predicate is how an entity gets announced
|
||||
that nothing feeds; a roster makes it how a SUM gets taken over a different
|
||||
list from the one that was announced. It is one function now.
|
||||
|
||||
⚠️ `pv_entities` REPLACES `pv_entity` RATHER THAN EXTENDING IT, and that is
|
||||
CFG-04's shipped precedent (`inverters:` replaces the six flat keys) applied
|
||||
to the only upgrade path that matters. The install this card exists for 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 in the
|
||||
other direction, in the one channel that exists to be believed a week later.
|
||||
Under REPLACE the effective roster is exactly what one key says, and no
|
||||
arithmetic depends on a de-duplication rule the operator has to know about.
|
||||
⚠️ `Controller.checks()` says WHICH key is in use whenever both are set, so
|
||||
the replacement is stated on the page rather than being silent.
|
||||
|
||||
⚠️ AND IT DE-DUPLICATES WITHIN THE LIST, ORDER PRESERVED. A site cannot have
|
||||
two sensors with one entity_id, so a repeated id in the list is never a
|
||||
second inverter - it is a typo that would DOUBLE that inverter's production
|
||||
in the total. `dict.fromkeys` is the order-preserving spelling.
|
||||
"""
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get("pv_entities") or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
one = str(opts.get("pv_entity") or "").strip()
|
||||
return (one,) if one else ()
|
||||
|
||||
|
||||
def entity_is_ignored(opts: dict) -> bool:
|
||||
"""Is a set `pv_entity` being REPLACED by a set `pv_entities`? TEL-12.
|
||||
|
||||
⚠️ HERE RATHER THAN IN `Controller.checks()`, for the reason `entities()`
|
||||
exists: the rule and the sentence that reports the rule must not be two
|
||||
readings of the options. This is the negative half of `entities()`' first
|
||||
branch and it is spelled once, beside it, where an edit to one is in front
|
||||
of whoever is editing the other.
|
||||
"""
|
||||
return bool(str(opts.get("pv_entity") or "").strip()
|
||||
and [x for x in (opts.get("pv_entities") or [])
|
||||
if str(x or "").strip()])
|
||||
|
||||
|
||||
def total(readings) -> tuple:
|
||||
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
|
||||
|
||||
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
|
||||
per configured entity, each `state` shaped as `hass.state()` returns.
|
||||
|
||||
`parts` is `[(entity_id, watts, why), ...]` - EVERY entity's own answer from
|
||||
`watts()`, kept because the operator surfaces have to be able to name which
|
||||
sensor is missing. `watts` is the sum when every entity answered, and `None`
|
||||
otherwise; `why` always says which of those it was, in words a status page
|
||||
can print.
|
||||
|
||||
⚠️ ONE OR MORE ABSENT -> THE TOTAL IS ABSENT. NOT THE PARTIAL SUM. This is
|
||||
the decision the card turns on and it was taken deliberately against the
|
||||
alternative, which was to publish the partial sum ALONGSIDE a count of how
|
||||
many entities contributed. Both are defensible; this one 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's attribution
|
||||
and by whatever 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,
|
||||
which is the same "nobody will re-derive it" TEL-11's note names. 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 sum makes it say "there
|
||||
was less sun than there was".
|
||||
* the STATUS PAGE is read LIVE, by a human, who wants the diagnostic. So
|
||||
the partial information is not thrown away - `Controller.checks()` names
|
||||
every entity that DID report, with its watts, and every one that did
|
||||
not, with its reason, in a row that 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 this function actually admits: the total is published ONLY when every
|
||||
entity contributed, so the count would be the constant `len(parts)`.
|
||||
|
||||
⚠️ AND `0 W` IS STILL A READING AT EVERY ROSTER SIZE - the test is
|
||||
`watts is None`, never falsiness. An inverter honestly reporting 0 W at dusk
|
||||
beside two that are producing must give the SUM of the three, not blank the
|
||||
site: shading, a tripped string and nightfall are all real 0s, and a roster
|
||||
that went to `unknown` whenever one member read zero would report an absence
|
||||
on every clear evening and defeat the channel from the other end.
|
||||
"""
|
||||
parts = [(ent, *watts(st)) for ent, st in readings]
|
||||
if not parts:
|
||||
# No roster. `Controller` does not call this in that state - the gate is
|
||||
# `pv_roster` - but a reader of `parts` should never have to wonder.
|
||||
return None, "not configured", parts
|
||||
if len(parts) == 1:
|
||||
# ⚠️ AT ONE ENTITY THIS IS `watts()` AND NOTHING ELSE: the same value
|
||||
# and the SAME `why` string, passed through rather than re-composed, so
|
||||
# 0.11.0's single-entity surfaces are byte-identical rather than
|
||||
# similar. `tools/tel11_pv_trace.py` grades that identity across trees.
|
||||
return parts[0][1], parts[0][2], parts
|
||||
missing = [(ent, why) for ent, w, why in parts if w is None]
|
||||
if missing:
|
||||
return None, (
|
||||
f"only {len(parts) - len(missing)} of {len(parts)} entities are "
|
||||
f"reporting and a partial sum would understate production: "
|
||||
+ "; ".join(f"{ent} - {why}" for ent, why in missing)), parts
|
||||
return (sum(w for _e, w, _y in parts),
|
||||
f"{len(parts)} entities", parts)
|
||||
|
||||
Reference in New Issue
Block a user