diff --git a/goodwe_controller/DECISIONS/SAFETY-118-peak-forecast-unit.md b/goodwe_controller/DECISIONS/SAFETY-118-peak-forecast-unit.md index aae71fd..37422d7 100644 --- a/goodwe_controller/DECISIONS/SAFETY-118-peak-forecast-unit.md +++ b/goodwe_controller/DECISIONS/SAFETY-118-peak-forecast-unit.md @@ -4,8 +4,8 @@ `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 -real site did 430 times in one morning, was taken as 17 to 45 kW. The validator -also called a kW sensor wrong, which was false for this entity. +real site did 430 times in one morning, would be taken as 17 to 45 kW. The +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 same unit table and reader as the solar entities, so W, kW, MW and the rest diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index ac6d57d..9e98549 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -3472,7 +3472,9 @@ class Controller: # power: not used, as if the option were empty. st = await self.hass.state(o["peak_forecast_entity"]) 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: self.peak_fc = None self._peak_fc_fault = None diff --git a/goodwe_controller/app/pv.py b/goodwe_controller/app/pv.py index fb70296..901e0c9 100644 --- a/goodwe_controller/app/pv.py +++ b/goodwe_controller/app/pv.py @@ -126,19 +126,26 @@ def watts(state: dict | None) -> tuple: 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`. - """ +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 None + return False raw = str(state.get("state", "")).strip() if raw.lower() in BAD_STATES: - return None + 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 diff --git a/goodwe_controller/test_safety118.py b/goodwe_controller/test_safety118.py index cc8319b..30058a6 100644 --- a/goodwe_controller/test_safety118.py +++ b/goodwe_controller/test_safety118.py @@ -178,15 +178,22 @@ check("no unit is a different cause and warns", len(warns()) == 3 and "has no unit" in warns()[2]) reads(c) 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") 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") reads(c) -check("a good unit is read again and logs INFO, not a warning", - c.peak_fc == 820.0 and len(warns()) == 3 - and rec.rows[-1] == (logging.INFO, - "The 15-minute demand forecast is used again.")) +check("a good unit is read again: one INFO, no new warning", + c.peak_fc == 820.0 and len(warns()) == 3 and len(used()) == 1 + and rec.rows[-1] == USED) n = len(warns()) for s in (st(820, "W"), st(0.8, "kW")): 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", len({messages.peak_forecast_unit(u, l) for u in ("", "kWh") 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 and pv.unit_fault(st("unavailable")) is None and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh")