Files
goodwe-addon/goodwe_controller/app/pv.py
T
glenn schrooyenandClaude f0b4e68e49
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-118: read the peak forecast in its own unit
peak_forecast_entity is read through pv.watts() by its unit_of_measurement;
the abs(raw) < 50 size rule is gone. No unit or a non-power unit means the
forecast is not used, with one warning per cause. The validator no longer
claims a kW sensor is taken as watts for this entity.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 16:00:34 +02:00

293 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 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). A missing, unavailable or
non-numeric state is a missing reading, not a unit fault, so it is `None`.
"""
if state is None:
return None
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
return None
try:
float(raw)
except ValueError:
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)