OPT-38: a kept tomorrow forecast reading stands in at any hour
checks / unit (pull_request) Successful in 22m54s
infra-pin / fresh (pull_request) Successful in 1s

One stale Forecast.Solar tomorrow sensor dropped the whole OPT-17 sum from
17:54 to 20:00. Today readings and the OPT-34 morning rules are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-07 04:24:01 +01:00
co-authored by Claude
parent b291262ee1
commit c43873b142
6 changed files with 140 additions and 7 deletions
+1
View File
@@ -5,6 +5,7 @@
- Home now draws today's charge from the grid for every hour the add-on will really charge, not only the run under way ([[UX-77]]).
- When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]).
- With a 5 s meter, readings 30 s late are now noticed within 100 seconds, not 3 minutes ([[SAFETY-129]]).
- If one of the tomorrow solar forecast sensors stops updating in the afternoon, tomorrow's forecast now stays on the Forecast tab instead of vanishing until 20:00 ([[OPT-38]]).
## 0.20.1
@@ -44,3 +44,5 @@ tonight." (`home_words.solar.kept`, EN and NL). The event log says
**Not changed.** Control with modes off: it does not use the forecast. The
screens still show the line, so it says "kept", not "used for the plan". A
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
**OPT-38 (2026-11-07).** A kept TOMORROW reading (at most `KEPT_MAX_AGE_S` old, same `day0`) now stands in at any hour, not only from `BELL_TO_H`: one sensor that stopped at 14:54 had dropped the whole sum (OPT-17) from 17:54 to 20:00. Today readings and the OPT-34 morning rules are unchanged; `test_opt38.py`.
+5 -3
View File
@@ -34,7 +34,9 @@ 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, if it is at most `KEPT_MAX_AGE_S` old when used.
midnight, if it is at most `KEPT_MAX_AGE_S` old when used. A TOMORROW
reading needs no evening for (b), at any hour (OPT-38: one Forecast.Solar
sensor stopped at 14:54 and tomorrow vanished from 17:54 to 20:00).
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 the bell starts
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
@@ -347,8 +349,8 @@ class Hold:
return (day, at, True) if ok else None
if age > KEPT_MAX_AGE_S:
return None
# A tomorrow sensor is asked with day0 = the next midnight, which is
# in the future all day, so the evening ends at that day's end.
if kind == "tomorrow":
return day, at, True # OPT-38: no evening needed
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
return (day, at, True) if evening else None
+3 -2
View File
@@ -144,8 +144,9 @@ rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
"no tomorrow, nothing kept",
fd is not None and fd.tomorrow is None and not fd.kept)
"the 09:05 figure covers it, kept (OPT-38: at any hour)",
fd is not None and fd.tomorrow is not None
and fd.tomorrow.kwh == 12.0 and fd.kept)
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
fn is not None and fn.tomorrow is not None
+3 -2
View File
@@ -186,8 +186,9 @@ check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
"no tomorrow (the 3rd's figure is not the 5th's)",
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
"tomorrow is the 03:50 figure for the 5th (9.0, kept: OPT-38), never "
"the 3rd's 12.0", fy2 is not None and fy2.today.kwh == 5.0
and fy2.tomorrow is not None and fy2.tomorrow.kwh == 9.0 and fy2.kept)
# Restart: the saved hold keeps the figure.
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
+126
View File
@@ -0,0 +1,126 @@
"""Runnable check for OPT-38. `python3 test_opt38.py`
Live 0.20.1, 2026-11-06: one of three Forecast.Solar tomorrow sensors last
reported 14:54. From 17:54 (over FRESH_S) to 20:00 (BELL_TO_H) nothing stood in
for it, the OPT-17 sum was dropped and the Forecast tab had no tomorrow. A kept
tomorrow reading now stands in at any hour (`Hold.stand_in()`). Today members,
and the OPT-34 morning rules, are as before.
Mutations (MUTATION.md, purged copies): see the pull request.
"""
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from app import solarfc # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
def st(kwh, when):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
"last_updated": when.isoformat(), "last_reported": when.isoformat()}
TODAY = [("sensor.t", None)]
M = ["sensor.m1", "sensor.m2", "sensor.m3"]
def run(hold, readings, now, today_at=None):
t = [("sensor.t", st(0.5, today_at or now - timedelta(minutes=5)))]
return solarfc.read_sum(
t, [(e, st(k, w)) for e, (k, w) in zip(M, readings)], now, hold)
# The live day: m2 last reported 14:54, the others keep reporting.
h = solarfc.Hold()
run(h, [(4.0, at(6, 14, 0)), (5.0, at(6, 14, 54)), (6.0, at(6, 14, 0))],
at(6, 14, 55))
fresh = lambda w: [(4.0, w), (5.0, at(6, 14, 54)), (6.0, w)] # noqa: E731
f, why, gaps = run(h, fresh(at(6, 18, 20)), at(6, 18, 30))
check("18:30, one member last reported 14:54: tomorrow sum is there, kept",
f is not None and f.tomorrow is not None and f.tomorrow.kwh == 15.0
and f.kept and gaps == ())
f, _w, _g = run(h, fresh(at(6, 17, 40)), at(6, 17, 50))
check("17:50 (just under 3 h): fresh, not kept",
f is not None and f.tomorrow.kwh == 15.0 and not f.kept)
f, _w, _g = run(h, fresh(at(6, 17, 55)), at(6, 18, 0))
check("18:00 (stale since 17:54): kept, tomorrow there",
f is not None and f.tomorrow is not None and f.kept)
f, _w, _g = run(h, fresh(at(6, 19, 50)), at(6, 19, 55))
check("19:55 (before the bell ends): tomorrow there", f.tomorrow is not None)
# Too old: over KEPT_MAX_AGE_S (12 h) when used.
h2 = solarfc.Hold()
run(h2, [(4.0, at(6, 5, 0)), (5.0, at(6, 5, 0)), (6.0, at(6, 5, 0))],
at(6, 5, 5))
w = at(6, 16, 50)
f, why, gaps = run(h2, [(4.0, w), (5.0, at(6, 5, 0)), (6.0, w)],
at(6, 17, 10))
check("member 12 h 10 min old: no tomorrow sum (OPT-17 rule), today stays",
f is not None and f.tomorrow is None
and [g[0] for g in gaps] == ["tomorrow"])
f, _w, _g = run(h2, [(4.0, w), (5.0, at(6, 5, 0)), (6.0, w)],
at(6, 16, 50))
check("... and 11 h 50 min old is still kept",
f is not None and f.tomorrow is not None and f.kept)
# Not a partial sum: a member never read has nothing to stand in.
f, _w, gaps = run(solarfc.Hold(), [(4.0, at(6, 18, 0)),
(5.0, at(6, 14, 54)),
(6.0, at(6, 18, 0))], at(6, 18, 30))
check("a member with no kept reading: no tomorrow sum, no partial sum",
f is not None and f.tomorrow is None and len(gaps) == 1)
# Today members are as before: a stale today member at 18:30 is not kept.
h3 = solarfc.Hold()
solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
[(M[0], st(9.0, at(6, 14, 54)))], at(6, 14, 55), h3)
f, why, _g = solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
[(M[0], st(9.0, at(6, 18, 20)))],
at(6, 18, 30), h3)
check("stale today member at 18:30: not kept, no forecast",
f is None and why == solarfc.STALE)
f, _w, _g = solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
[(M[0], st(9.0, at(6, 20, 0)))],
at(6, 20, 30), h3)
check("... and at 20:30 it is kept, as before (the bell has ended)",
f is not None and f.kept)
# After midnight: a tomorrow reading from before midnight is never tomorrow's.
h4 = solarfc.Hold()
run(h4, [(4.0, at(6, 14, 0)), (5.0, at(6, 14, 0)), (6.0, at(6, 14, 0))],
at(6, 14, 5))
for label, now in (("00:30", at(7, 0, 30)), ("10:00", at(7, 10, 0)),
("15:00", at(7, 15, 0))):
f, _w, _g = solarfc.read_sum(
[("sensor.t", st(2.0, now - timedelta(minutes=5)))],
[(e, st(k, at(6, 14, 0))) for e, k in zip(M, (4.0, 5.0, 6.0))],
now, h4)
check(f"{label} on the 7th: the 6th's tomorrow figure is not the "
"7th's tomorrow", f is not None and f.tomorrow is None)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")