Files
goodwe-addon/goodwe_controller/app/solarfc.py
T
glenn schrooyenandClaude Opus 5.5 fbd884797d OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- 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>
2026-09-29 22:08:47 +02:00

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