From 13b61d586a65be8dec37200b9e8763f06a119b07 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 30 Oct 2026 20:41:34 +0100 Subject: [PATCH] OPT-27 round 3: a kept figure is at most 12 h old; DST-safe midnight bound; pruning test F1: the evening rule has an age limit (KEPT_MAX_AGE_S). F2: the evening bound is the next local midnight (_bounds), right on 25 h and 23 h days. F5: test that old days are dropped from the stored hold. Co-Authored-By: Claude --- ...PT-27-last-fresh-forecast-for-the-night.md | 2 +- goodwe_controller/DOCS.md | 2 +- goodwe_controller/app/solarfc.py | 11 ++- goodwe_controller/test_opt27.py | 71 +++++++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) 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 8d2c09f..8230585 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 @@ -26,7 +26,7 @@ sensor that is missing or stale, never one with a bad value or unit, and: until midnight + 3 h (all sensors of the sum are needed, as for any sum); - until the next fresh reading, which replaces it at once. -So a figure is never used for another day and never older than one night. +So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`): a 17:05 reading serves until 03:00 (9 h 55 min), a 09:05 reading only until 21:05. Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast all day is still no forecast (the night run still needs 1.5x). diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 76bdb13..4f58337 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -557,7 +557,7 @@ With battery modes on, these choices replace the *Charging and selling on price* - The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it. - No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so. - A battery needs its capacity (`inverters.capacity_kwh`) for a plan. -- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 03:00, or until a newer reading comes. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never older than that night. The Batteries screen says "Solar forecast read 17:05, kept for tonight." With no reading all day, the night run still needs the wider gap. +- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 03:00, or until a newer reading comes. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never when it is more than 12 hours old. The Batteries screen says "Solar forecast read 17:05, kept for tonight." With no reading all day, the night run still needs the wider gap. **What always comes first.** - The peak limit, the safety stops and the monthly cycle come before a battery's mode. diff --git a/goodwe_controller/app/solarfc.py b/goodwe_controller/app/solarfc.py index ac54caa..874b273 100644 --- a/goodwe_controller/app/solarfc.py +++ b/goodwe_controller/app/solarfc.py @@ -64,6 +64,9 @@ 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 +# OPT-27: and a reading older than this when used is not kept (a morning +# figure must not drive the night). +KEPT_MAX_AGE_S = 12 * 3600.0 SAVE_EVERY_S = 900.0 # Why there is no forecast (the page words are in ui_text `home_words`). @@ -318,7 +321,8 @@ 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, - or within `KEPT_UNTIL_H` after it. `kept` is true when (b) was needed, + or within `KEPT_UNTIL_H` after it, and then at most `KEPT_MAX_AGE_S` + old. `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)) @@ -327,11 +331,12 @@ class Hold: day, at = rec if (now - at).total_seconds() <= FRESH_S and not as_today: return day, at, False + if (now - at).total_seconds() > KEPT_MAX_AGE_S: + return None # Both sides: a tomorrow sensor is asked with day0 = the next # midnight, which is in the future all day (round 2). after_midnight = day0 <= now < day0 + timedelta(hours=KEPT_UNTIL_H) - evening = _local_hour(now) >= BELL_TO_H and now < day0 + timedelta( - days=1) + evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1] if as_today: return (day, at, True) if after_midnight else None return (day, at, True) if (evening or after_midnight) else None diff --git a/goodwe_controller/test_opt27.py b/goodwe_controller/test_opt27.py index 9643db3..2c6289e 100644 --- a/goodwe_controller/test_opt27.py +++ b/goodwe_controller/test_opt27.py @@ -250,6 +250,77 @@ check("an hourly split (Solcast shape) survives the store", fq is not None and fq.tomorrow.periods and abs(fq.between(at(4, 8, 0), at(4, 16, 0)) - 7.5) < 1e-6) +# Round 3. F1: a kept reading is at most KEPT_MAX_AGE_S old when used. +hz = solarfc.Hold() +rs(st(0.4, at(3, 0, 5)), st(12.0, at(3, 0, 5)), at(3, 0, 10), hz) +print(f" 00:05 reading, sensors dead: 19:59 " + f"{rs(GONE, GONE, at(3, 19, 59), hz)[0]}, 20:00 " + f"{rs(GONE, GONE, at(3, 20, 0), hz)[0]}") +check("F1: a 00:05 reading with the sensors dead all day is not kept at " + "20:00 (20 h old): no forecast", + rs(GONE, GONE, at(3, 20, 0), hz)[0] is None + and rs(GONE, GONE, at(3, 23, 0), hz)[0] is None) +check("... nor after midnight until 03:00 (as today's, 26 h old)", + rs(GONE, GONE, at(4, 0, 30), hz)[0] is None + and rs(GONE, GONE, at(4, 2, 30), hz)[0] is None) +hy = solarfc.Hold() +rs(st(0.4, at(3, 17, 5)), st(12.0, at(3, 17, 5)), at(3, 17, 10), hy) +check("F1: a 17:05 reading is kept at 22:00 (5 h) ...", + (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(3, 22, 0), hy)[0])) +check("... and at 02:59 next day (9 h 54 min), the midnight + 3 h bound ...", + (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 2, 59), + hy)[0])) +check("... and at 03:00 next day it is not (midnight + 3 h; 9 h 55 min old)", + rs(GONE, GONE, at(4, 3, 0), hy)[0] is None) +hm = solarfc.Hold() +rs(st(0.9, at(3, 9, 5)), st(12.0, at(3, 9, 5)), at(3, 9, 10), hm) +check("F1: a 09:05 reading is kept at 20:00 (10 h 55 min) but not at 22:00", + rs(GONE, GONE, at(3, 20, 0), hm)[0] is not None + and rs(GONE, GONE, at(3, 22, 0), hm)[0] is None) + +# F2: the 25 h autumn day (2026-10-25) and the 23 h spring day (2027-03-28). +BRU = solarfc.BILLING_TZ # Europe/Brussels, or the encoded EU rule + + +def bru(y, mo, d, hh, mm=0): + return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc) + + +for (y, mo, d), label in (((2026, 10, 25), "25 h autumn day 2026-10-25"), + ((2027, 3, 28), "23 h spring day 2027-03-28")): + hd = solarfc.Hold() + t17 = bru(y, mo, d, 17, 5) + rs(st(0.4, t17), st(12.0, t17), t17 + timedelta(minutes=5), hd) + # real hours from 20:00 local to the next local midnight, each hour + start = bru(y, mo, d, 20, 0) + end = bru(y, mo, d + 1, 0) + t, miss = start, [] + while t < end: + if rs(GONE, GONE, t, hd)[0] is None: + miss.append(t.astimezone(BRU).strftime("%H:%M")) + t += timedelta(minutes=30) + print(f" {label}: not kept at {miss}") + check(f"F2: {label}: the 17:05 figure is kept every half hour " + "from 20:00 to local midnight", not miss) + check(f"... and just before the next local midnight", + rs(GONE, GONE, bru(y, mo, d + 1, 0) - timedelta(minutes=1), + hd)[0] is not None) + +# F5: old days are dropped from the stored hold, so it does not grow. +hp5 = solarfc.Hold() +sizes = [] +for dd in range(3, 13): + t0_ = at(dd, 17, 5) + rs(st(0.4, t0_), st(12.0, t0_), t0_ + timedelta(minutes=5), hp5) + sizes.append(len(hp5.dump()["r"])) +day0s = {row[2] for row in hp5.dump()["r"]} +print(f" F5: stored entries per day over 10 days: {sizes}") +check("F5: the stored hold does not grow day after day (at most today's and " + "tomorrow's entry, with yesterday's tomorrow)", max(sizes) <= 3 + and len(day0s) <= 2) +check("... and a day before today is gone", + all(row[2] >= at(12, 0, 0).isoformat() for row in hp5.dump()["r"])) + # --------------------------------------------------------------------------- # print("\n2. The real Controller: day/night 0.34 / 0.26, cycle cost 0.07") A, B = "inverter-a", "inverter-b"