- Cheap hours only fills to the ceiling with or without a forecast, as the design (section 1) says. Skipping the sun's share cost 0.31 EUR on real 2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set. - A forecast reading from before local midnight is stale: until the integration refreshes, "left today" is still yesterday's 0 and "tomorrow" is really today. - Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the /api/states/<id> path). - House profile: a stored day that is not a date, or is after today, is dropped instead of kept forever. - Text: the forecast line says "for the rest of today"; the learning line no longer promises "tomorrow" (a plan needs one full day). - Tests for four surviving review mutants (hour sample floor, the dear run after the cheap run, share by room, daytime use) and the fixes above. - Replay tool: `--fc zero` (a forecast of no sun). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
9.5 KiB
Python
255 lines
9.5 KiB
Python
"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor.
|
|
|
|
Pure: HA state dicts and the time in, a `Forecast` (or None and why) out.
|
|
main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces
|
|
this later with the add-on's own forecast (owner answer, 2026-09-27).
|
|
|
|
Two options name the sensors: `pv_forecast_entity`, the solar energy still
|
|
to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it
|
|
is only needed for a cheap run before midnight). Supported, by the entities
|
|
they create (DOCS.md lists them):
|
|
|
|
* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and
|
|
`sensor.energy_production_tomorrow`. The state only; no hourly split.
|
|
* Solcast PV Forecast (HACS `solcast_solar`):
|
|
`sensor.solcast_pv_forecast_forecast_remaining_today` and
|
|
`..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`):
|
|
`[{period_start, pv_estimate}]`, kW per period.
|
|
* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`):
|
|
`sensor.energy_production_today_remaining` / `..._tomorrow`, with
|
|
`wh_period`: `{period start: Wh}`.
|
|
|
|
Any other sensor whose state is energy in Wh, kWh or MWh works too, as a
|
|
state only. The unit is read, never guessed (the rule `pv.watts()` has).
|
|
Without an hourly split, the day's energy is spread over a fixed bell from
|
|
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
|
|
|
|
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
|
and a stale or missing forecast is no forecast: the day plan is off and
|
|
every mode acts as in P3 option A.
|
|
"""
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from .hass import BAD_STATES
|
|
from .tariff import BILLING_TZ
|
|
|
|
KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0}
|
|
|
|
# Stale after 3 h. The integrations above refresh at least hourly, so a
|
|
# 2 h limit would flag a site whose hourly poll was missed once.
|
|
FRESH_S = 3 * 3600.0
|
|
|
|
# The bell a state-only forecast is spread over, in local hours.
|
|
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
|
|
|
# Why there is no forecast (the page words are in ui_text `home_words`).
|
|
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
|
"not_set", "missing", "unit", "stale", "bad")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Day:
|
|
"""One day's forecast: `kwh` still to come (today) or in all (tomorrow),
|
|
and the hourly split if the sensor has one: `((start, end, kwh), ...)`."""
|
|
day0: datetime # local midnight, as UTC
|
|
kwh: float
|
|
periods: tuple = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Forecast:
|
|
today: Day
|
|
tomorrow: Day | None
|
|
at: datetime # when the sensor last reported
|
|
|
|
def between(self, t0: datetime, t1: datetime) -> float | None:
|
|
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
|
None when the span reaches the sunny hours of a day with no
|
|
forecast (tomorrow, when its sensor is not set)."""
|
|
if t1 <= t0:
|
|
return 0.0
|
|
last = self.tomorrow or self.today
|
|
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
|
|
return None
|
|
total = 0.0
|
|
for day in (self.today, self.tomorrow):
|
|
if day is None:
|
|
continue
|
|
a = max(t0, day.day0)
|
|
b = min(t1, day.day0 + timedelta(days=1))
|
|
if b > a:
|
|
total += _day_between(day, a, b, t0)
|
|
return total
|
|
|
|
|
|
def _local_hour(t: datetime) -> float:
|
|
lt = t.astimezone(BILLING_TZ)
|
|
return lt.hour + lt.minute / 60.0 + lt.second / 3600.0
|
|
|
|
|
|
def _bell(h: float) -> float:
|
|
"""Cumulative share of a day's sun by local hour `h` (0..1)."""
|
|
if h <= BELL_FROM_H:
|
|
return 0.0
|
|
if h >= BELL_TO_H:
|
|
return 1.0
|
|
x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H)
|
|
return (1.0 - math.cos(math.pi * x)) / 2.0
|
|
|
|
|
|
def _hours_since(day0, t):
|
|
return (t - day0).total_seconds() / 3600.0
|
|
|
|
|
|
def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
|
|
if day.periods:
|
|
out = 0.0
|
|
for s, e, kwh in day.periods:
|
|
lo, hi = max(a, s), min(b, e)
|
|
if hi > lo:
|
|
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
|
|
return out
|
|
# State only: `kwh` is what is left from `max(now, day0)` to the day's
|
|
# end, spread over the bell.
|
|
h_from = _hours_since(day.day0, max(now, day.day0))
|
|
left = 1.0 - _bell(h_from)
|
|
if left <= 1e-9:
|
|
return 0.0
|
|
share = (_bell(_hours_since(day.day0, b))
|
|
- _bell(_hours_since(day.day0, a))) / left
|
|
return day.kwh * max(0.0, share)
|
|
|
|
|
|
def _parse_time(v):
|
|
try:
|
|
t = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def _periods(attrs: dict, day0: datetime) -> tuple:
|
|
"""The hourly split, if the sensor has one, for the day at `day0`."""
|
|
d1 = day0 + timedelta(days=1)
|
|
out = []
|
|
for key in ("detailedForecast", "detailedHourly"):
|
|
rows = attrs.get(key)
|
|
if isinstance(rows, list) and rows:
|
|
starts = []
|
|
for r in rows:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
t = _parse_time(r.get("period_start"))
|
|
try:
|
|
kw = float(r.get("pv_estimate"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if t is not None and math.isfinite(kw) and kw >= 0:
|
|
starts.append((t, kw))
|
|
starts.sort()
|
|
for i, (t, kw) in enumerate(starts):
|
|
step = (starts[i + 1][0] - t if i + 1 < len(starts)
|
|
else timedelta(minutes=30 if key ==
|
|
"detailedForecast" else 60))
|
|
if step <= timedelta(0) or step > timedelta(hours=1):
|
|
step = timedelta(minutes=30 if key == "detailedForecast"
|
|
else 60)
|
|
if day0 <= t < d1:
|
|
out.append((t, t + step,
|
|
kw * step.total_seconds() / 3600.0))
|
|
if out:
|
|
return tuple(out)
|
|
wh = attrs.get("wh_period")
|
|
if isinstance(wh, dict) and wh:
|
|
rows = []
|
|
for k, v in wh.items():
|
|
t = _parse_time(k)
|
|
try:
|
|
e = float(v)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if t is not None and math.isfinite(e) and e >= 0:
|
|
rows.append((t, e / 1000.0))
|
|
rows.sort()
|
|
for i, (t, kwh) in enumerate(rows):
|
|
nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta(
|
|
hours=1)
|
|
if nxt - t > timedelta(hours=1) or nxt <= t:
|
|
nxt = t + timedelta(hours=1)
|
|
if day0 <= t < d1:
|
|
out.append((t, nxt, kwh))
|
|
return tuple(out)
|
|
|
|
|
|
def _day(state, day0, now, *, today):
|
|
"""`(Day, None)` or `(None, why)` from one sensor's state dict."""
|
|
if state is None:
|
|
return None, MISSING
|
|
raw = str(state.get("state", "")).strip()
|
|
if raw.lower() in BAD_STATES:
|
|
return None, MISSING
|
|
try:
|
|
v = float(raw)
|
|
except ValueError:
|
|
return None, BAD
|
|
attrs = state.get("attributes") or {}
|
|
unit = str(attrs.get("unit_of_measurement") or "").strip()
|
|
if unit not in KWH_PER:
|
|
return None, UNIT
|
|
kwh = v * KWH_PER[unit]
|
|
if not math.isfinite(kwh) or kwh < 0:
|
|
return None, BAD
|
|
periods = _periods(attrs, day0)
|
|
if periods and today:
|
|
# The split is the whole day; keep what is still to come, scaled so
|
|
# it adds up to the state (the state is what the integration says
|
|
# is left).
|
|
left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds()
|
|
/ (e - s).total_seconds()))
|
|
for s, e, k in periods)
|
|
if left > 1e-6:
|
|
f = kwh / left
|
|
periods = tuple((s, e, k * f) for s, e, k in periods)
|
|
return Day(day0, kwh, periods), None
|
|
|
|
|
|
def _reported(state):
|
|
ts = [_parse_time(state.get(k)) for k in
|
|
("last_reported", "last_updated", "last_changed")]
|
|
ts = [t for t in ts if t is not None]
|
|
return max(ts) if ts else None
|
|
|
|
|
|
def read(today_state, tomorrow_state, now: datetime, *,
|
|
configured=True):
|
|
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
|
(the option is not set, or the sensor is missing): the forecast then
|
|
covers today only."""
|
|
if not configured:
|
|
return None, NOT_SET
|
|
here = now.astimezone(BILLING_TZ)
|
|
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
|
nd = d0.date() + timedelta(days=1)
|
|
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
|
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
|
today, why = _day(today_state, day0, now, today=True)
|
|
if today is None:
|
|
return None, why
|
|
at = _reported(today_state)
|
|
# A reading from before local midnight is yesterday's "left today" (0
|
|
# after sunset) until the integration refreshes: stale, not a dark day.
|
|
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
|
or (at - now).total_seconds() > 300:
|
|
return None, STALE
|
|
tomorrow = None
|
|
if tomorrow_state is not None:
|
|
t, _w = _day(tomorrow_state, day1, now, today=False)
|
|
t_at = _reported(tomorrow_state)
|
|
if t is not None and t_at is not None and t_at >= day0 \
|
|
and (now - t_at).total_seconds() <= FRESH_S:
|
|
tomorrow = t
|
|
return Forecast(today, tomorrow, at), None
|