OPT-27: the night plan keeps the last fresh solar forecast

Forecast.Solar's sensors stop updating in the evening, so at night every such
site had no forecast: the night cheap run needed 1.5x the cycle cost and
Leftover solar planned nothing. One missed poll also dropped it at once.

solarfc.Hold keeps the last fresh reading of each sensor, for the day it is
for. A missing or stale sensor is covered by it for up to FRESH_S (a short
outage), and from the end of the bell (20:00) until local midnight + 3 h
however old. A tomorrow figure is tomorrow's until midnight and then today's;
a today figure is only today's. Stored in the controller's store
(solar_fc_hold), so an evening restart keeps it. The forecast line says
'Solar forecast read 17:05, used for tonight's plan.' (EN, NL).

test_tariff24_weather.py runs with a hold that never covers: it tests what the
plan does when the forecast IS lost.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-29 21:06:09 +01:00
co-authored by Claude
parent 665653517c
commit cdcbe7dbb6
10 changed files with 787 additions and 21 deletions
+2 -1
View File
@@ -368,7 +368,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
if fc is not None:
if k == 0:
line = hv.line_solar(fc.today.kwh, fc.today.kwh * trust,
hv.hhmm(fc.at))
hv.hhmm(fc.at),
kept=getattr(fc, "kept", False))
elif fc.tomorrow is not None:
line = hv.day_note("tomorrow_total",
kwh=hv._kwh(fc.tomorrow.kwh),
+7 -2
View File
@@ -543,7 +543,8 @@ def solar_block(c, now):
out["line"] = _t("solar." + st)
elif fc is not None:
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
out["updated"])
out["updated"],
kept=getattr(fc, "kept", False))
return out
@@ -1395,7 +1396,11 @@ def day_note(key, **values) -> str:
return _t("day." + key, **values)
def line_solar(fc_kwh, count_kwh, at) -> str:
def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
"""The forecast line. OPT-27: a reading kept past three hours (the
evening's, used at night) says so instead of "Updated"."""
if kept:
return _t("solar.kept", at=at)
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
+17 -4
View File
@@ -208,6 +208,9 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
# how often the forecast sensors are read (they change hourly at most).
HOUSE_PROFILE_KEY = "house_profile"
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
# evening restart keeps the figure the night plan uses.
FC_HOLD_KEY = "solar_fc_hold"
FORECAST_READ_S = 60.0
@@ -995,6 +998,8 @@ class Controller:
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
else solarfc.MISSING)
self._fc_read_mono = None
self.fc_hold = solarfc.Hold(
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
self.day_plan = None
self._plan_targets = None
self.house_profile = None
@@ -3406,17 +3411,25 @@ class Controller:
now = datetime.now(timezone.utc)
fc, why, gaps = solarfc.read_sum(
list(zip(self.fc_entities, states[:n])),
list(zip(self.fc_tomorrow_entities, states[n:])), now)
list(zip(self.fc_tomorrow_entities, states[n:])), now,
hold=self.fc_hold)
if self.fc_hold.changed:
self.fc_hold.changed = False
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
# A member is named only in a sum of several; one entity reads as
# before. Logged once per episode: on a change, never every read.
gaps = tuple(g for g in gaps if len(
self.fc_entities if g[0] == "today"
else self.fc_tomorrow_entities) > 1)
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
if (fc is None) != (self.solar_fc is None) \
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
or bool(fc is not None and fc.kept) != was_kept:
self.log_event(
"solar forecast: " + ("read" if fc is not None else
f"none ({why})")
"solar forecast: " + (
"none (" + str(why) + ")" if fc is None else
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
else "read")
+ "".join(f", {d} {e} not usable ({w})"
for d, _i, e, w in gaps),
hint=messages.hint(
+174 -11
View File
@@ -28,6 +28,17 @@ 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.
OPT-27: but the last fresh reading of each sensor is kept (`Hold`), so the
night plan is not made blind. Forecast.Solar's sensors stop changing in the
evening and api.forecast.solar is sometimes out for a poll. A kept reading
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
or unavailable, as a sensor that merely stopped updating is already
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
midnight + `KEPT_UNTIL_H` however old it is. Never beyond that, and never
for another day: a today figure is only today's, a tomorrow figure is
tomorrow's until midnight and then, until midnight + 3 h, today's.
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
OPT-17: each day may instead name a list (`pv_forecast_entities`,
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
not usable makes that day's sum unusable, never a partial sum.
@@ -50,6 +61,11 @@ 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
# OPT-27: a kept reading serves the night plan from the end of the bell until
# this many hours after local midnight (the card: "local midnight + 3 h").
KEPT_UNTIL_H = 3.0
SAVE_EVERY_S = 900.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")
@@ -75,6 +91,10 @@ class Forecast:
today: Day
tomorrow: Day | None
at: datetime # when the sensor last reported
# OPT-27: a reading kept past `FRESH_S` is in it (the evening's figure at
# night). The screens say so. Last, so `Forecast(today, tomorrow, at)` is
# as before.
kept: bool = False
def between(self, t0: datetime, t1: datetime) -> float | None:
"""kWh the sun should give between `t0` (now or later) and `t1`.
@@ -234,6 +254,106 @@ def _reported(state):
return max(ts) if ts else None
class Hold:
"""OPT-27: the last fresh reading of each forecast sensor, per day it is
for. `read_sum()` remembers every fresh reading here and, when a sensor
has none now, asks it (`stand_in()`).
Keyed `(kind, entity, day0)`: `kind` is "today" (energy still to come on
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
FOR. A today figure therefore never answers for another day, and a
tomorrow figure answers for its own day only: tomorrow until midnight,
then today until midnight + `KEPT_UNTIL_H`. Only the newest reading of a
key is kept, so the next fresh reading ends the hold at once.
`dump()` / `load()` are what the controller keeps in its store, so an
evening restart does not lose it (`load()` drops what is bad).
`changed` is set when `dump()` would differ."""
def __init__(self, data=None):
self._r = {}
self._saved = {} # key -> the `at` last flagged for saving
self.changed = False
if isinstance(data, dict):
self.load(data)
def remember(self, kind, entity, day: "Day", at: datetime) -> None:
key = (kind, entity, day.day0)
old = self._r.get(key)
if old is not None and at <= old[1]:
return
self._r[key] = (day, at)
# A sensor that reports every minute with the same figure must not
# rewrite the store every minute: the saved age may lag by
# `SAVE_EVERY_S` (the bounds are hours).
mark = self._saved.get(key)
if old is None or old[0].kwh != day.kwh or mark is None \
or (at - mark).total_seconds() >= SAVE_EVERY_S:
self._saved[key] = at
self.changed = True
def forget_before(self, day0: datetime) -> None:
"""Drop what is for a day before `day0` (the local midnight now)."""
gone = [k for k in self._r if k[2] < day0]
for k in gone:
del self._r[k]
self._saved.pop(k, None)
if gone:
self.changed = True
def stand_in(self, kind, entity, day0, now, *, as_today=False):
"""`(Day, at, kept)` for a sensor that has no fresh reading, or None.
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
old, or (b) used at night: from the end of the bell to local midnight,
or within `KEPT_UNTIL_H` after it. `kept` is true when (b) was needed,
or `as_today` (a tomorrow figure standing in for today, after
midnight)."""
rec = self._r.get((kind, entity, day0))
if rec is None:
return None
day, at = rec
if (now - at).total_seconds() <= FRESH_S and not as_today:
return day, at, False
after_midnight = now < day0 + timedelta(hours=KEPT_UNTIL_H)
evening = _local_hour(now) >= BELL_TO_H and now < day0 + timedelta(
days=1)
if as_today:
return (day, at, True) if after_midnight else None
return (day, at, True) if (evening or after_midnight) else None
def dump(self) -> dict:
return {"v": 1, "r": [
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
[[s.isoformat(), e.isoformat(), k] for s, e, k in d.periods]]
for (kind, ent, _d0), (d, at) in sorted(
self._r.items(), key=lambda kv: (kv[0][0], kv[0][1],
kv[0][2]))]}
def load(self, data: dict) -> None:
rows = data.get("r") if isinstance(data, dict) else None
if not isinstance(rows, list):
return
for row in rows:
try:
kind, ent, d0, kwh, at, per = row
day0, at = _parse_time(d0), _parse_time(at)
kwh = float(kwh)
periods = tuple((_parse_time(s), _parse_time(e), float(k))
for s, e, k in per)
ok = (kind in ("today", "tomorrow") and isinstance(ent, str)
and day0 is not None and at is not None
and math.isfinite(kwh) and kwh >= 0
and all(s is not None and e is not None and e > s
and math.isfinite(k) and k >= 0
for s, e, k in periods))
except (TypeError, ValueError):
continue
if ok:
self._r[(kind, ent, day0)] = (Day(day0, kwh, periods), at)
self._saved[(kind, ent, day0)] = at
def entities(opts: dict, *, tomorrow=False) -> tuple:
"""The forecast entities one day names, in configured order: the list
if it has any entry, else the single key, else (). A repeated id is
@@ -270,14 +390,14 @@ def _today(state, day0, now):
def _tomorrow(state, day0, day1, now):
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
"""`(Day, at, None)` or `(None, None, why)` for one tomorrow sensor."""
t, why = _day(state, day1, now, today=False)
if t is None:
return None, why
return None, None, why
t_at = _reported(state)
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
return None, STALE
return t, None
return None, None, STALE
return t, t_at, None
def read(today_state, tomorrow_state, now: datetime, *,
@@ -293,7 +413,7 @@ def read(today_state, tomorrow_state, now: datetime, *,
return None, why
tomorrow = None
if tomorrow_state is not None:
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
tomorrow, _at, _w = _tomorrow(tomorrow_state, day0, day1, now)
return Forecast(today, tomorrow, at), None
@@ -309,7 +429,12 @@ def _sum(days):
return Day(days[0].day0, sum(d.kwh for d in days), periods)
def read_sum(today, tomorrow, now: datetime):
# Why a sensor may be covered by a kept reading: it has none now, or it is
# too old. A bad value or a wrong unit is a set-up fault and is never covered.
_COVERABLE = (MISSING, STALE)
def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
`(entity, state)`, one per configured member, in configured order.
@@ -322,29 +447,67 @@ def read_sum(today, tomorrow, now: datetime):
`gaps` is `((day, position, entity, why), ...)` for every unusable
member (`day` "today" or "tomorrow", `position` 1-based), so the page
and the log can name it. With one member per day the forecast is what
`read()` gives (test_opt17.py proves it)."""
`read()` gives (test_opt17.py proves it).
OPT-27: with a `hold`, every fresh reading is remembered in it, and a
member that is missing or stale is covered by its last fresh reading
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
that took a reading older than `FRESH_S`). After midnight, if the today
side is still short, the tomorrow figures read before midnight are
today's, until midnight + `KEPT_UNTIL_H`. Without a `hold` this is
exactly as before."""
if not today:
return None, NOT_SET, ()
day0, day1 = _bounds(now)
gaps, days, ats, why0 = [], [], [], None
if hold is not None:
hold.forget_before(day0)
gaps, days, ats, why0, kept = [], [], [], None, False
for i, (ent, st) in enumerate(today, 1):
d, at, why = _today(st, day0, now)
if d is not None and hold is not None:
hold.remember("today", ent, d, at)
elif hold is not None and why in _COVERABLE:
r = hold.stand_in("today", ent, day0, now)
if r is not None:
d, at, k = r
kept = kept or k
if d is None:
gaps.append(("today", i, ent, why))
why0 = why0 or why
else:
days.append(d)
ats.append(at)
tdays = []
tdays, tats, tkept = [], [], False
for i, (ent, st) in enumerate(tomorrow, 1):
t, why = _tomorrow(st, day0, day1, now)
t, t_at, why = _tomorrow(st, day0, day1, now)
if t is not None and hold is not None:
hold.remember("tomorrow", ent, t, t_at)
elif hold is not None and why in _COVERABLE:
r = hold.stand_in("tomorrow", ent, day1, now)
if r is not None:
t, t_at, k = r
tkept = tkept or k
if t is None:
gaps.append(("tomorrow", i, ent, why))
else:
tdays.append(t)
tats.append(t_at)
if why0 is not None and hold is not None and tomorrow and all(
g[3] in _COVERABLE for g in gaps if g[0] == "today"):
# Past midnight the sensors may still show yesterday's "left today".
# The figure each tomorrow sensor gave before midnight is for this
# day; all of them are needed, as for any sum.
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
for ent, _st in tomorrow]
if all(r is not None for r in subs):
days = [r[0] for r in subs]
ats = [r[1] for r in subs]
gaps = [g for g in gaps if g[0] != "today"]
why0, kept = None, True
gaps = tuple(gaps)
if why0 is not None:
return None, why0, gaps
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
kept = kept or (tday is not None and tkept)
# The oldest member's report is when the sum was last fully known.
return Forecast(_sum(days), tday, min(ats)), None, gaps
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
+1 -1
View File
@@ -360,7 +360,7 @@
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "kept": "Solar forecast read {at}, used for tonight's plan.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
+1 -1
View File
@@ -300,7 +300,7 @@
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "kept": "Zonneprognose van {at}, gebruikt voor het plan van vanavond.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",