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
260 lines
14 KiB
Python
260 lines
14 KiB
Python
"""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_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
|
|
NOWHERE. Three causes need three different fixes (no irradiance: nothing to fix;
|
|
a charge-power limit; the optimiser declining to charge - the only software
|
|
defect), and guessing between them is how an optimiser gets tuned to solve a
|
|
cloud problem. This module is the missing measurement and NOTHING ELSE.
|
|
|
|
⚠️ NOTHING HERE MAY EVER REACH A CONTROL DECISION, AND THAT IS THE CARD'S FIRST
|
|
CONSTRAINT RATHER THAN A STYLE NOTE. The add-on regulates a real battery; adding
|
|
production as a term of the control law would be an unrequested behaviour change
|
|
AND it would destroy the measurement, because the loop would then be reacting to
|
|
the quantity being measured. `tools/tel11_pv_trace.py` is what says the path is
|
|
untouched: it grades a control document that contains none of this material on
|
|
both trees and with `pv_entity` set and unset.
|
|
|
|
⚠️ ABSENCE IS NEVER ZERO, AND THAT IS THE SECOND CONSTRAINT. Every refusal below
|
|
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. ⚠️ 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:
|
|
|
|
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
|
|
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
|
|
|
|
Both are "positive = power arriving at the house's AC bus", and production
|
|
arrives there too. There is deliberately NO `pv_invert` to go with
|
|
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
|
|
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
|
|
check and SAFETY-31's circulating-power sensor with a plausible number the site
|
|
then acts on - whereas production is unipolar and is read by nothing. A reversed
|
|
PV sensor announces itself as -4 kW at noon in the very history this card
|
|
creates, and flipping it here would only hide it. See
|
|
`DECISIONS/TEL-11-pv-production.md` §2.
|
|
"""
|
|
|
|
from .hass import BAD_STATES
|
|
|
|
# Home Assistant's OWN power units, each as the number of watts one of it is.
|
|
# ⚠️ READ OUT OF HA, NOT LISTED FROM MEMORY: `UnitOfPower` in
|
|
# `homeassistant/const.py` (github.com/home-assistant/core, `dev`, retrieved
|
|
# 2026-09-24) declares mW, W, kW, MW, GW, TW and BTU/h. Every factor here is an
|
|
# exact power of ten, so none of them is a number anybody chose.
|
|
#
|
|
# ⚠️ BTU/h IS IN HA's ENUM AND DELIBERATELY NOT HERE. Its factor (0.293071…)
|
|
# is a measured conversion rather than a decimal shift, no PV integration
|
|
# publishes in it, and a wrong constant in the one channel that exists to
|
|
# attribute a 20.5 kWh shortfall is worse than a refusal an operator can read.
|
|
# It lands in the "not a power unit" refusal below, BY NAME.
|
|
WATTS_PER = {
|
|
"mW": 0.001,
|
|
"W": 1.0,
|
|
"kW": 1000.0,
|
|
"MW": 1000000.0,
|
|
"GW": 1000000000.0,
|
|
"TW": 1000000000000.0,
|
|
}
|
|
|
|
# What `watts()` answers when there is nothing to report. ⚠️ THE REASON IS PART
|
|
# OF THE RETURN VALUE, not a log line: it is what the status page prints, and an
|
|
# absence whose cause is invisible sends an operator to look at the wrong box.
|
|
UNIT_KEY = "unit_of_measurement"
|
|
|
|
|
|
def watts(state: dict | None) -> tuple:
|
|
"""`(watts, why)` from a Home Assistant state object.
|
|
|
|
`watts` is `None` whenever there is no reading - and `why` then says which
|
|
of the five refusals it was, in words an operator can act on. On a good read
|
|
`why` is the unit the value was read in, because "1234 W" and "1234, unit
|
|
unknown" are different claims and the page has to be able to make the first.
|
|
|
|
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
|
|
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
|
|
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
|
|
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
|
|
real production at dusk is an ordinary reading, and that heuristic would
|
|
publish it as 30 kW - a fabricated number, in the channel whose entire
|
|
purpose is to be believed a week later. A sensor whose unit this cannot read
|
|
is REFUSED rather than guessed at.
|
|
"""
|
|
if state is None:
|
|
# `hass.state()` answers None for a non-existent entity, a 404 and a
|
|
# transport failure alike. All three are "no reading", and HA does not
|
|
# consider a non-existent entity an error - see `hass.state()`.
|
|
return None, "no state (entity missing, or the read failed)"
|
|
raw = str(state.get("state", "")).strip()
|
|
if raw.lower() in BAD_STATES:
|
|
# ⚠️ THE SAME LIST `hass.number()` REFUSES ON, IMPORTED RATHER THAN
|
|
# RESPELLED. A second copy of it is how one path starts treating
|
|
# `unavailable` as a number the other one rejects.
|
|
return None, f"entity is {raw or 'empty'}"
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
return None, f"state {raw!r} is not numeric"
|
|
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
|
|
if not unit:
|
|
return None, ("no unit_of_measurement on the entity - it cannot be "
|
|
"read in watts")
|
|
if unit not in WATTS_PER:
|
|
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)
|