"""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 homeview, 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 (not " "flagged kept before 20:00)", f is not None and f.tomorrow is not None and f.tomorrow.kwh == 15.0 and not 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): tomorrow there", f is not None and f.tomorrow is not None and not 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 not 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) # Before 20:00 a kept tomorrow reading is not "kept for tonight": today's # line stays the normal one (EN and NL) and the event log (main.py, from # `fc.kept`) has no "kept". h5 = solarfc.Hold() solarfc.read_sum([("t", st(3.0, at(6, 9, 0)))], [("m", st(12.0, at(6, 9, 5)))], at(6, 9, 6), h5) for hh in (14, 18, 19): f, _w, _g = solarfc.read_sum([("t", st(2.5, at(6, hh - 1, 55)))], [("m", st(12.0, at(6, 9, 5)))], at(6, hh, 0), h5) check(f"{hh}:00, today fresh, tomorrow kept from 09:05: tomorrow there, " "not flagged kept", f is not None and f.tomorrow is not None and f.tomorrow.kwh == 12.0 and f.kept is False) f, _w, _g = solarfc.read_sum([("t", st(2.5, at(6, 13, 55)))], [("m", st(12.0, at(6, 9, 5)))], at(6, 14, 0), h5) for lang in ("en", "nl"): with homeview.in_lang(lang): line = homeview.line_solar(f.today.kwh, f.today.kwh * 0.7, homeview.hhmm(f.at), kept=f.kept) kept_line = homeview.line_solar(1, 1, homeview.hhmm(f.at), kept=True) print(f" {lang}: {line!r}") if lang == "en": check("... EN says 'Solar still to come today'", line.startswith("Solar still to come today")) check(f"14:00 Home line ({lang}) is the normal line, not the kept one", line != kept_line and "2,5" in line.replace(".", ",") and "13:55" in line) f, _w, _g = solarfc.read_sum([("t", st(0.2, at(6, 20, 55)))], [("m", st(12.0, at(6, 9, 5)))], at(6, 21, 0), h5) check("21:00 as before: tomorrow kept from 09:05 is flagged kept", f is not None and f.kept and f.tomorrow.kwh == 12.0) print() if fails: print(f"{len(fails)} FAILED:") for f in fails: print(" -", f) sys.exit(1) print("all checks passed")