SAFETY-118: read the peak forecast in its own unit
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s

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>
This commit is contained in:
glenn schrooyen
2026-10-16 16:00:34 +02:00
co-authored by Claude
parent 780c1f67bb
commit f0b4e68e49
11 changed files with 415 additions and 16 deletions
+30 -3
View File
@@ -1202,6 +1202,9 @@ class Controller:
# `grid` is P1, one meter, one connection, and belongs here.
self.grid = None
self.peak_fc = None
# SAFETY-118: the unit fault last logged for the peak forecast (None =
# no fault), so the warning comes once and again only if it changes.
self._peak_fc_fault = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
# PRICE-01. Which step of the price chain the prices come from (logged
@@ -3358,6 +3361,22 @@ class Controller:
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
def _peak_fc_unit_log(self, fault) -> None:
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
in watts, and again only if the unit changes; the unit is on DEBUG."""
if fault == self._peak_fc_fault:
return
self._peak_fc_fault = fault
if fault is None:
_LOG.info("The 15-minute demand forecast is used again.")
elif fault == "":
_LOG.warning("The 15-minute demand forecast has no unit, so it "
"is not used.")
else:
_LOG.warning("The 15-minute demand forecast is not in a power "
"unit, so it is not used.")
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
async def _read_forecast(self) -> None:
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
failed or stale read is no forecast, never a zero."""
@@ -3448,11 +3467,15 @@ class Controller:
_reads.append(self._read_forecast())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw)
# SAFETY-118: read in its own unit, like the PV path. One state
# read, as `hass.number()` made. No unit or a unit that is not
# 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))
else:
self.peak_fc = None
self._peak_fc_fault = None
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
if now_e and avg_e:
@@ -7240,6 +7263,10 @@ class Controller:
# is both is a check nobody reads.
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_no_peak_forecast()})
elif getattr(self, "_peak_fc_fault", None) is not None:
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_peak_forecast_unit(
self._peak_fc_fault)})
# OPT-17: a forecast summed from several entities names, by position,
# each member with no usable reading. A caution: control goes on.
gaps = getattr(self, "solar_fc_gaps", ())
+21
View File
@@ -788,6 +788,25 @@ def row_no_peak_forecast() -> str:
"the peak limit")
def peak_forecast_unit(unit, lang="en") -> str:
"""SAFETY-118. The 15-minute demand forecast has a number but no power
unit, so it is not used. `unit` is "" for none. One sentence, for the log
line, the status row and the settings page. Log lines stay English."""
if lang == "nl":
return ("De kwartierprognose heeft geen eenheid, dus ze wordt niet "
"gebruikt." if not unit else
"De kwartierprognose heeft geen vermogenseenheid, dus ze "
"wordt niet gebruikt.")
return ("The 15-minute demand forecast has no unit, so it is not used."
if not unit else
"The 15-minute demand forecast is not in a power unit, so it is "
"not used.")
def row_peak_forecast_unit(unit) -> str:
return peak_forecast_unit(unit)
def row_peak_guard_waiting() -> str:
return "Peak guard: no decision yet, control has not run"
@@ -1229,6 +1248,8 @@ def status_samples() -> list:
("row_no_estop", AMBER, row_no_estop()),
("row_estop_fitted", GREEN, row_estop_fitted()),
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("")),
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("kWh")),
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
("row_pv", GREEN, row_pv(3200, 2)),
("row_pv", GREEN, row_pv(1800, 1, True)),
+24 -6
View File
@@ -93,12 +93,12 @@ def watts(state: dict | None) -> tuple:
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
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:
@@ -126,6 +126,24 @@ 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`.
"""
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:
+1 -1
View File
@@ -194,7 +194,7 @@ GROUPS = (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5, note="month_peak"),
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_forecast_entity", ENTITY, kind="power", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
+7 -2
View File
@@ -1741,11 +1741,12 @@ def _card_note(option: str, spec) -> str:
# UX-13: entity options whose number the add-on takes as watts, with no unit
# conversion. pv.py converts each solar entity's own unit, so those are not here.
# conversion. pv.py converts each solar entity's own unit, so those are not here,
# and SAFETY-118 moved the peak forecast there too.
WATT_ENTITY_KEYS = (
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
"p1_phase_net_entities", "p1_phase_import_entities",
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
"p1_phase_export_entities", "batt_entity")
# The meter entities each `meter_source` reads through Home Assistant.
@@ -1829,6 +1830,10 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
_advise(notes, "warn", (key,),
messages.peak_forecast_unit(unit) + " Pick a sensor in "
"W or kW.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "