A missing reading (unavailable, unknown, failed read, non-numeric) no longer clears the fault: no false 'used again' INFO, no repeated WARNING, the status row stays. Adds pv.has_reading(). DECISIONS wording fixed; tests added. Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
16 KiB
Python
300 lines
16 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, as for `meter_entity` and
|
|
`batt_entity` ("power arriving at the house's AC bus"). TEL-14: a kWh meter or
|
|
power clamp in the PV circuit reads the other way, so `pv_invert_entities`
|
|
flips the sign per entity. See `DECISIONS/TEL-14-pv-sign.md`.
|
|
"""
|
|
|
|
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. 30 W of real production at dusk is an ordinary reading,
|
|
and a "small number means kW" rule would publish it as 30 kW - a fabricated
|
|
number, in the channel whose entire purpose is to be believed a week later.
|
|
SAFETY-118 removed the last such rule, on `peak_forecast_entity`, for the
|
|
same reason (DECISIONS/SAFETY-118-peak-forecast-unit.md): the peak forecast
|
|
now reads through this function too. 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
|
|
|
|
|
|
def has_reading(state: dict | None) -> bool:
|
|
"""SAFETY-118. True when `state` holds a number, so its unit can be judged.
|
|
A missing, unavailable, unknown or non-numeric state is a missing reading."""
|
|
if state is None:
|
|
return False
|
|
raw = str(state.get("state", "")).strip()
|
|
if raw.lower() in BAD_STATES:
|
|
return False
|
|
try:
|
|
float(raw)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def unit_fault(state: dict | None):
|
|
"""SAFETY-118. `None` unless `state` holds a number whose unit cannot be
|
|
read in watts, then that unit (`""` for none). `None` also means a missing
|
|
reading: use `has_reading()` to tell it from a good unit."""
|
|
if not has_reading(state):
|
|
return None
|
|
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
|
|
return None if unit in WATTS_PER else 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 inverted(opts: dict) -> frozenset:
|
|
"""The entity ids `pv_invert_entities` flips. TEL-14."""
|
|
return frozenset(e for e in (str(x or "").strip()
|
|
for x in (opts.get("pv_invert_entities") or []))
|
|
if e)
|
|
|
|
|
|
def stray_inverts(opts: dict) -> list:
|
|
"""`pv_invert_entities` entries naming no entity in the roster - a typo
|
|
that would otherwise leave a sign unflipped with nothing said. TEL-14."""
|
|
roster = set(entities(opts))
|
|
return sorted(e for e in inverted(opts) if e not in roster)
|
|
|
|
|
|
# TEL-14: a reading below this is not standby draw. A correctly signed meter in
|
|
# the PV circuit reads a few watts NEGATIVE at night (the owner's: 13 W); -50 W
|
|
# clears that with room for a couple of such devices on one entity, while a
|
|
# reversed sensor in daylight reads hundreds to thousands of watts below zero.
|
|
NEGATIVE_W = -50.0
|
|
|
|
|
|
def total(readings, flip=frozenset()) -> 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.
|
|
|
|
⚠️ TEL-14: `flip` is `inverted(opts)`; each member's sign is applied HERE,
|
|
before the sum, so every surface reads the corrected figure. `0.0 - w`
|
|
rather than `-w`, so a flipped 0 W is `0.0` and never `-0.0` on the wire.
|
|
"""
|
|
parts = [(ent, w if w is None or ent not in flip else 0.0 - w, why)
|
|
for ent, (w, why) in ((e, watts(st)) for e, 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)
|