From c43873b142530b0da30286b5583e556fd569cd11 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 7 Nov 2026 04:24:01 +0100 Subject: [PATCH 1/2] OPT-38: a kept tomorrow forecast reading stands in at any hour 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 --- goodwe_controller/CHANGELOG.md | 1 + ...PT-27-last-fresh-forecast-for-the-night.md | 2 + goodwe_controller/app/solarfc.py | 8 +- goodwe_controller/test_opt27.py | 5 +- goodwe_controller/test_opt34.py | 5 +- goodwe_controller/test_opt38.py | 126 ++++++++++++++++++ 6 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 goodwe_controller/test_opt38.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index f5c1b6e..6ebdc4f 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md b/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md index ae9d68a..11f6932 100644 --- a/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md +++ b/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md @@ -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`. diff --git a/goodwe_controller/app/solarfc.py b/goodwe_controller/app/solarfc.py index 3928d71..3cda6dc 100644 --- a/goodwe_controller/app/solarfc.py +++ b/goodwe_controller/app/solarfc.py @@ -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 diff --git a/goodwe_controller/test_opt27.py b/goodwe_controller/test_opt27.py index 0967f3e..1d0e7a8 100644 --- a/goodwe_controller/test_opt27.py +++ b/goodwe_controller/test_opt27.py @@ -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 diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index 869e7fc..6165010 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -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))): diff --git a/goodwe_controller/test_opt38.py b/goodwe_controller/test_opt38.py new file mode 100644 index 0000000..d4ef310 --- /dev/null +++ b/goodwe_controller/test_opt38.py @@ -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") -- 2.54.0 From 0bacafadf04f814efbae7702d9b3fa3750a1121d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 7 Nov 2026 06:02:00 +0100 Subject: [PATCH 2/2] OPT-38 round 2: a kept tomorrow reading is flagged kept only from 20:00 Before the bell ends it replaced today's Home line and the event log with the kept-for-tonight wording. stand_in() docstring covers the tomorrow case. Co-Authored-By: Claude --- goodwe_controller/app/solarfc.py | 10 ++++--- goodwe_controller/test_opt27.py | 2 +- goodwe_controller/test_opt34.py | 4 +-- goodwe_controller/test_opt38.py | 45 +++++++++++++++++++++++++++----- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/goodwe_controller/app/solarfc.py b/goodwe_controller/app/solarfc.py index 3cda6dc..6a4939e 100644 --- a/goodwe_controller/app/solarfc.py +++ b/goodwe_controller/app/solarfc.py @@ -332,8 +332,9 @@ class Hold: 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, - at most `KEPT_MAX_AGE_S` old. `kept` is true when (b) was needed, - or `as_today` (OPT-34: a tomorrow figure standing in for today, after + at most `KEPT_MAX_AGE_S` old; a "tomorrow" reading at any hour + (OPT-38; `read_sum()` flags it kept only from the end of the bell). + `kept` is true when (b) was needed, or `as_today` (OPT-34: a tomorrow figure standing in for today, after midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`).""" rec = self._r.get((kind, entity, day0)) if rec is None: @@ -558,6 +559,9 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None): 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) + # OPT-38: before the bell ends a kept tomorrow reading is no "kept for + # tonight": it must not replace today's line or the log. + kept = kept or (tday is not None and tkept + and _local_hour(now) >= BELL_TO_H) # The oldest member's report is when the sum was last fully known. return Forecast(_sum(days), tday, min(ats), kept), None, gaps diff --git a/goodwe_controller/test_opt27.py b/goodwe_controller/test_opt27.py index 1d0e7a8..7f3809d 100644 --- a/goodwe_controller/test_opt27.py +++ b/goodwe_controller/test_opt27.py @@ -146,7 +146,7 @@ for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)): check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): " "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) + and fd.tomorrow.kwh == 12.0 and not 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 diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index 6165010..fdb4698 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -186,9 +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: " - "tomorrow is the 03:50 figure for the 5th (9.0, kept: OPT-38), never " + "tomorrow is the 03:50 figure for the 5th (9.0, not flagged kept before 20:00: 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) + and fy2.tomorrow is not None and fy2.tomorrow.kwh == 9.0 and not 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))): diff --git a/goodwe_controller/test_opt38.py b/goodwe_controller/test_opt38.py index d4ef310..11ce381 100644 --- a/goodwe_controller/test_opt38.py +++ b/goodwe_controller/test_opt38.py @@ -16,7 +16,7 @@ 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 +from app import homeview, solarfc # noqa: E402 fails = [] @@ -55,15 +55,16 @@ 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", +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 f.kept and gaps == ()) + 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): kept, tomorrow there", - f is not None and f.tomorrow is not None and f.kept) +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) @@ -80,7 +81,7 @@ check("member 12 h 10 min old: no tomorrow sum (OPT-17 rule), today stays", 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) + 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)), @@ -117,6 +118,38 @@ for label, now in (("00:30", at(7, 0, 30)), ("10:00", at(7, 10, 0)), 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:") -- 2.54.0