SAFETY-118: a missing peak forecast reading keeps the unit-fault state
checks / unit (pull_request) Successful in 40m19s
infra-pin / fresh (pull_request) Successful in 1s

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>
This commit is contained in:
glenn schrooyen
2026-10-16 18:19:44 +02:00
co-authored by Claude
parent f0b4e68e49
commit 7d52dc1c0f
4 changed files with 97 additions and 16 deletions
@@ -4,8 +4,8 @@
`peak_forecast_entity` was read as a bare number, and anything under 50 was `peak_forecast_entity` was read as a bare number, and anything under 50 was
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
real site did 430 times in one morning, was taken as 17 to 45 kW. The validator real site did 430 times in one morning, would be taken as 17 to 45 kW. The
also called a kW sensor wrong, which was false for this entity. validator also called a kW sensor wrong, which was false for this entity.
Decision: the size rule is gone. The entity is read through `pv.watts()`, the Decision: the size rule is gone. The entity is read through `pv.watts()`, the
same unit table and reader as the solar entities, so W, kW, MW and the rest same unit table and reader as the solar entities, so W, kW, MW and the rest
+3 -1
View File
@@ -3472,7 +3472,9 @@ class Controller:
# power: not used, as if the option were empty. # power: not used, as if the option were empty.
st = await self.hass.state(o["peak_forecast_entity"]) st = await self.hass.state(o["peak_forecast_entity"])
self.peak_fc, _why = pv_read.watts(st) self.peak_fc, _why = pv_read.watts(st)
self._peak_fc_unit_log(pv_read.unit_fault(st)) # A missing reading leaves the unit-fault state as it was.
if pv_read.has_reading(st):
self._peak_fc_unit_log(pv_read.unit_fault(st))
else: else:
self.peak_fc = None self.peak_fc = None
self._peak_fc_fault = None self._peak_fc_fault = None
+14 -7
View File
@@ -126,19 +126,26 @@ def watts(state: dict | None) -> tuple:
return value * WATTS_PER[unit], unit return value * WATTS_PER[unit], unit
def unit_fault(state: dict | None): def has_reading(state: dict | None) -> bool:
"""SAFETY-118. `None` unless `state` holds a number whose unit cannot be """SAFETY-118. True when `state` holds a number, so its unit can be judged.
read in watts, then that unit (`""` for none). A missing, unavailable or A missing, unavailable, unknown or non-numeric state is a missing reading."""
non-numeric state is a missing reading, not a unit fault, so it is `None`.
"""
if state is None: if state is None:
return None return False
raw = str(state.get("state", "")).strip() raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES: if raw.lower() in BAD_STATES:
return None return False
try: try:
float(raw) float(raw)
except ValueError: 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 return None
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip() unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
return None if unit in WATTS_PER else unit return None if unit in WATTS_PER else unit
+78 -6
View File
@@ -178,15 +178,22 @@ check("no unit is a different cause and warns", len(warns()) == 3
and "has no unit" in warns()[2]) and "has no unit" in warns()[2])
reads(c) reads(c)
check("...and only once", len(warns()) == 3) check("...and only once", len(warns()) == 3)
USED = (logging.INFO, "The 15-minute demand forecast is used again.")
def used():
return [r for r in rec.rows if r == USED]
h.fc = st("unavailable") h.fc = st("unavailable")
reads(c) reads(c)
check("unavailable is no unit fault and adds no warning", len(warns()) == 3) check("unavailable adds no warning and no 'used again'",
len(warns()) == 3 and not used())
h.fc = st(820, "W") h.fc = st(820, "W")
reads(c) reads(c)
check("a good unit is read again and logs INFO, not a warning", check("a good unit is read again: one INFO, no new warning",
c.peak_fc == 820.0 and len(warns()) == 3 c.peak_fc == 820.0 and len(warns()) == 3 and len(used()) == 1
and rec.rows[-1] == (logging.INFO, and rec.rows[-1] == USED)
"The 15-minute demand forecast is used again."))
n = len(warns()) n = len(warns())
for s in (st(820, "W"), st(0.8, "kW")): for s in (st(820, "W"), st(0.8, "kW")):
h.fc = s h.fc = s
@@ -291,7 +298,72 @@ print(" NL bad unit:", messages.peak_forecast_unit("kWh", "nl"))
check("the strings differ by cause and by language", check("the strings differ by cause and by language",
len({messages.peak_forecast_unit(u, l) for u in ("", "kWh") len({messages.peak_forecast_unit(u, l) for u in ("", "kWh")
for l in ("en", "nl")}) == 4) for l in ("en", "nl")}) == 4)
check("pv.unit_fault: None for a good unit and for a missing reading", print("6. a missing reading leaves the unit fault as it was (F1)")
lg.addHandler(rec)
def prow(c):
return [r for r in c.checks() if r.get("id") == "peak_forecast"]
def flap(first, gap, last):
"""first, gap, first again (or last): the log rows and the status row
after each cycle."""
rec.rows.clear()
c, h = make(first)
out = []
for s in (first, gap, last):
h.fc = s
reads(c)
out.append((len(warns()), len(used()), len(prow(c)), c.peak_fc))
return out
for label, first, gap in [
("kWh -> unavailable -> kWh", st(820, "kWh"), st("unavailable", "kWh")),
("kWh -> unknown -> kWh", st(820, "kWh"), st("unknown", "kWh")),
("kWh -> failed read -> kWh", st(820, "kWh"), None),
("kWh -> non-numeric -> kWh", st(820, "kWh"), st("sunny", "kWh")),
("no unit -> failed read -> no unit", st(820), None),
("no unit -> unavailable -> no unit", st(820), st("unavailable"))]:
r = flap(first, gap, first)
check(f"{label}: one WARNING, no 'used again', row on all 3 ({r})",
[x[0] for x in r] == [1, 1, 1] and [x[1] for x in r] == [0, 0, 0]
and [x[2] for x in r] == [1, 1, 1]
and [x[3] for x in r] == [None, None, None])
r = flap(st(820, "kWh"), st("unavailable", "kWh"), st(820, "W"))
check(f"kWh -> unavailable -> W: the fault clears only on the W reading ({r})",
[x[1] for x in r] == [0, 0, 1] and [x[2] for x in r] == [1, 1, 0]
and [x[0] for x in r] == [1, 1, 1] and r[2][3] == 820.0)
rec.rows.clear()
c, h = make(st(820, "W"))
for s in (st(820, "W"), st("unavailable", "W"), None, st("sunny", "W")):
h.fc = s
reads(c)
check(f"good unit, reading {s and s['state']!r}: no row, no log",
not prow(c) and not warns() and not used())
rec.rows.clear()
c, h = make(st(820, "kWh"))
reads(c)
for s in (st("unavailable", "kWh"), None):
h.fc = s
reads(c)
check("fault row stays while the reading is missing",
len(prow(c)) == 1 and prow(c)[0]["text"]
== messages.row_peak_forecast_unit("kWh"))
c, h = make(st(820, "W"))
h.fc = st("unavailable", "W")
reads(c)
check("good-unit sensor only unavailable: the fault row stays away",
not prow(c))
lg.removeHandler(rec)
check("has_reading: numbers only",
pv.has_reading(st(0, "kWh")) and pv.has_reading(st("1.5"))
and not pv.has_reading(None) and not pv.has_reading(st("unavailable"))
and not pv.has_reading(st("unknown")) and not pv.has_reading(st("x")))
check("pv.unit_fault: None for a good unit and for a missing reading (has_reading tells them apart)",
pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None
and pv.unit_fault(st("unavailable")) is None and pv.unit_fault(st("unavailable")) is None
and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh") and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh")