OPT-34: the night charge keeps its level when the kept forecast ends
checks / unit (pull_request) Successful in 18m59s
infra-pin / fresh (pull_request) Successful in 1s

With the forecast sensors silent from 17:05 the kept evening figure ended at
midnight + 3 h, in the middle of the night grid charge, and Leftover solar's
planned level went with it (rig: A 49.8 % against 79.9 %).

- A night grid charge that has started keeps its planned level when the forecast
  goes (Controller._latch_charge, store key night_charge_latch), and the narrow
  price gap with it (weather_held). A fresh figure still moves the level.
- The evening's tomorrow figure is today's until the bell starts (06:00 local),
  up to 15 h old but only if it was usable at midnight (solarfc.Hold.stand_in).
- Screen: "No solar forecast now. The charge under way goes on to its planned
  level." (EN/NL) while a latched battery is below its level.
- test_opt34.py (whole nights through the real control cycle, mutants), test_opt27
  and test_modes_p4 adjusted to the new window and the kept level.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-31 17:00:31 +01:00
co-authored by Claude
parent deee98d352
commit 412e47f111
12 changed files with 783 additions and 64 deletions
+42 -24
View File
@@ -34,11 +34,13 @@ 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`, if it is at most `KEPT_MAX_AGE_S` old when used.
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.
midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
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`
old, and it must have been usable at midnight).
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
DECISIONS/OPT-34-the-night-charge-keeps-its-level.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
@@ -63,11 +65,16 @@ FRESH_S = 3 * 3600.0
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).
# local midnight, 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
# OPT-34: the evening's "tomorrow" figure, once midnight has passed, is today's
# until the bell starts (`BELL_FROM_H`): nothing has been produced yet, so it
# is still the whole day. A 17:05 reading is 12 h 55 min old at 06:00, so this
# case may be older than `KEPT_MAX_AGE_S`, up to this, but only a figure that
# was usable at midnight (at most `KEPT_MAX_AGE_S` old then), so one that has
# not been used all evening does not come back at 00:00.
KEPT_MORNING_MAX_AGE_S = 15 * 3600.0
SAVE_EVERY_S = 900.0
# Why there is no forecast (the page words are in ui_text `home_words`).
@@ -279,7 +286,7 @@ class Hold:
`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
then today until the bell starts (OPT-34). 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
@@ -322,25 +329,27 @@ 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, 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)."""
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
midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`)."""
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:
age = (now - at).total_seconds()
if age <= 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 < _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
ok = (day0 <= now < _bell_start(day0)
and age <= KEPT_MORNING_MAX_AGE_S
and (day0 - at).total_seconds() <= KEPT_MAX_AGE_S)
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.
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
return (day, at, True) if evening else None
def dump(self) -> dict:
return {"v": 1, "r": [
@@ -395,6 +404,15 @@ def _bounds(now):
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
def _bell_start(day0):
"""The moment the bell starts on the local day that begins at `day0`
(`BELL_FROM_H` local, so 06:00 on a 23 h or 25 h day too)."""
here = day0.astimezone(BILLING_TZ)
t = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) \
+ timedelta(hours=BELL_FROM_H)
return t.astimezone(timezone.utc)
def _today(state, day0, now):
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
today, why = _day(state, day0, now, today=True)
@@ -474,7 +492,7 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
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
today's, until the bell starts (`stand_in()`). Without a `hold` this is
exactly as before."""
if not today:
return None, NOT_SET, ()