Skip to content

Commit 42a4df5

Browse files
Plan history: show the latest finished slot, and stop the false 'stale' warning (#5414)
* fix(plan history): rebuild once per completed slot, and judge staleness by each view's own refresh interval The plan page's History view is built by calculate_yesterday() from what actually happened, at most once every 59 minutes. The live Plan view starts at the current slot, so the slots finished since the last rebuild showed in neither view: at 21:35 the History stopped at 20:30 and the Plan started at 21:30. And the page judged every view against the live plan's 15-minute stale limit, so on the History view it warned "Plan data is stale" for about 45 minutes of every hour although nothing was wrong. - calculate_yesterday() now rebuilds once per completed plan slot (plan_interval_minutes), on the first re-plan at least one run (5 minutes) after the slot ends, so the boundary run's own cost_today write is recorded before its history is read back. A rebuild takes 0.4-0.9 s on a live install; at the default 30-minute slots that is 48 a day instead of 24 (before #2913 it ran on every re-plan, about 144). - Each dataset the page shows carries refresh_minutes, the longest Predbat should take to republish it (the live plan: calculate_plan_every; the plan history: a slot, the run it waits and a re-plan interval). The page warns a run after that, never under 15 minutes, against the data the current view shows, and hides the warning when the view has no data. At the defaults: Plan 15 minutes (as before), History 50. Trade-offs, kept: with calculate_plan_every at or above the slot length, re-plans can fall on slot boundaries and the rebuild then happens there; cost_yesterday and savings_yesterday_* roll over at the first re-plan after 00:05 rather than at 00:00; cost_yesterday/savings_yesterday_predbat, with their large html/json attributes, are written twice as often. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plan history): use RUN_EVERY, not PREDICT_STEP, for the one-run wait history_slot() and history_refresh_minutes() wait one run past a slot boundary, and their docstrings call that run PREDICT_STEP. One run is RUN_EVERY in const.py; PREDICT_STEP is the prediction step. Both are 5 today, so nothing changes now, but if either changed the wait would quietly be wrong. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plan history): keep the repeated hour at the autumn clock change as separate slots history_slot() keyed slots by local date and wall-clock slot only, so the first and second 01:35 on the night the clocks go back collapsed to one key. With an hourly re-plan the second run was skipped and the History view went two hours without a rebuild, past its advertised refresh interval. Add the UTC offset to the key, and step back the one run in UTC rather than on the wall clock, so the step lands on the right side of the change (pytz keeps the old offset on wall-clock arithmetic; zoneinfo drops fold). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plan history): rebuild on the midnight re-plan again The one-run wait counts the 00:00 run towards the last slot of the day before, so when the previous rebuild was already in that slot the forced midnight re-plan skipped it. With an hourly re-plan, cost_yesterday and the savings_yesterday sensors went on showing the day before yesterday until 01:00; main rebuilt at 00:00 because the date had changed. Rebuild whenever the local date has changed as well as when the slot has. Also correct a test comment that still called the schedule hourly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0dbcce3 commit 42a4df5

7 files changed

Lines changed: 303 additions & 89 deletions

File tree

‎apps/predbat/output.py‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
import math
2020
import copy
2121
from html import escape as escape_html
22-
from datetime import timedelta
22+
from datetime import timedelta, timezone
2323
from predbat import THIS_VERSION_DISPLAY
24-
from const import TIME_FORMAT, PREDICT_STEP, EXPORT_LIMIT_IDLE, MINUTE_WATT, FULL_EXPORT_POWER, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, CHARGE_STATE_PRECEDENCE, EXPORT_STATE_PRECEDENCE
24+
from const import TIME_FORMAT, PREDICT_STEP, RUN_EVERY, EXPORT_LIMIT_IDLE, MINUTE_WATT, FULL_EXPORT_POWER, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, CHARGE_STATE_PRECEDENCE, EXPORT_STATE_PRECEDENCE
2525
from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate, export_mode_of, export_target_of, export_power_of, export_limit_sort_key, pack_export_limit, export_limit_from_stored
2626
from prediction import Prediction
2727

@@ -1939,6 +1939,8 @@ def import_rate_color(rate):
19391939
raw_plan["timestamp"] = self.now_utc_real.isoformat()
19401940

19411941
if publish:
1942+
# The live plan is republished on every re-plan; the web page's stale check reads this
1943+
raw_plan["refresh_minutes"] = self.calculate_plan_every
19421944
self.dashboard_item(self.prefix + ".plan_html", state="", attributes={"text": self.text_plan, "html": html, "raw": raw_plan, "friendly_name": "Plan in HTML", "icon": "mdi:web-box"})
19431945

19441946
return html, raw_plan
@@ -3193,26 +3195,46 @@ def plan_row_holding_for_car(self, minute_start, minute_end, car_hold_minutes=No
31933195
held = sum(PREDICT_STEP for minute in range(minute_start, minute_end, PREDICT_STEP) if (minute - self.minutes_now) in self.predict_car_hold_best)
31943196
return held * 2 >= minute_end - minute_start
31953197

3198+
def history_slot(self, when):
3199+
"""Which plan slot the plan history rebuild at when counts towards, as (date, slot number from midnight, UTC offset).
3200+
3201+
Slots are plan_interval_minutes long, shifted one run (RUN_EVERY) later: the run at a slot boundary
3202+
writes predbat.cost_today seconds before calculate_yesterday() reads its history back from Home
3203+
Assistant, so the slot just finished is rebuilt on the next run, once that value is recorded. This only
3204+
helps when that next run re-plans; with calculate_plan_every at or above the slot length, re-plans can
3205+
fall on boundaries and the rebuild then happens there.
3206+
3207+
The UTC offset keeps the hour repeated when the clocks go back as separate slots, and the run is
3208+
stepped back in UTC so the step itself lands on the right side of the change.
3209+
"""
3210+
when = (when.astimezone(timezone.utc) - timedelta(minutes=RUN_EVERY)).astimezone(when.tzinfo)
3211+
return when.date(), (when.hour * 60 + when.minute) // self.plan_interval_minutes, when.utcoffset()
3212+
3213+
def history_refresh_minutes(self):
3214+
"""The longest the plan history should go between rebuilds: a slot, the run it waits, and up to one re-plan interval."""
3215+
return self.plan_interval_minutes + RUN_EVERY + self.calculate_plan_every
3216+
31963217
def calculate_yesterday(self):
31973218
"""
31983219
Calculate the base plan for yesterday
31993220
"""
32003221

3201-
# Check savings_last_updated timestamp, we don't need to re-compute this one every iteration, once an hour or when the day rolls over is enough
3202-
if self.savings_last_updated:
3203-
# savings_last_update is a timestamp object, compare age and date
3204-
age = self.now_utc - self.savings_last_updated
3205-
if age < timedelta(minutes=59) and self.savings_last_updated.date() == self.now_utc.date():
3206-
# Less than an hour old and already updated today
3207-
return
3222+
# Recompute once per completed plan slot rather than every iteration: the plan history (the web page's
3223+
# History view) shows each slot from what actually happened, so rebuilding as soon as a slot ends keeps the
3224+
# last finished slot on show - the live Plan view starts at the current slot, so an hourly rebuild left the
3225+
# slots since it in neither view. Each rebuild takes under a second, and it waits one run past the boundary
3226+
# (see history_slot) so the boundary run's own writes are recorded. A day rolling over rebuilds straight away,
3227+
# on the midnight re-plan, so the *_yesterday sensors move on to the day just ended without waiting a re-plan.
3228+
if self.savings_last_updated and self.savings_last_updated.date() == self.now_utc.date() and self.history_slot(self.savings_last_updated) == self.history_slot(self.now_utc):
3229+
return
32083230

32093231
# Everything below is anchored on yesterday's recorded cost, so fetch that before doing any of
32103232
# the expensive work - when Home Assistant isn't recording predbat.cost_today there is nothing
32113233
# to compute and the step data and rate scans below would only be thrown away again
32123234
cost_today_data = self.get_history_wrapper(entity_id=self.prefix + ".cost_today", days=2, required=False)
32133235
if not cost_today_data:
32143236
self.log("Warn: Calculate yesterday: No history for {}.cost_today, so the savings and plan history can not be computed - check that Home Assistant is recording this entity (see the recorder notes in the FAQ)".format(self.prefix))
3215-
# Record the attempt so this is retried on the normal hourly cadence rather than every cycle
3237+
# Record the attempt so this is retried in the next slot rather than every cycle
32163238
self.savings_last_updated = self.now_utc
32173239
return
32183240

@@ -3256,7 +3278,7 @@ def calculate_yesterday(self):
32563278
# Assume user might charge at the lowest rate only, for fixed tariff
32573279
# Only use yesterday's rate range (k < end_record) for the threshold to prevent today's rates
32583280
# (which are added progressively as minutes_now increases) from changing the baseline charge
3259-
# windows on each hourly recalculation and causing savings_yesterday to fluctuate.
3281+
# windows on each recalculation and causing savings_yesterday to fluctuate.
32603282
charge_window_best = []
32613283
rate_low = self.compute_rate_low_for_yesterday(past_rates, end_record)
32623284
combine_charge = self.combine_charge_slots
@@ -3447,6 +3469,7 @@ def calculate_yesterday(self):
34473469
self.export_limits_best = []
34483470
self.export_window_best = []
34493471
plan_html_baseline, plan_json_baseline = self.plan_write_debug(True, None, yesterday_pv_step, yesterday_pv_step, yesterday_load_step, yesterday_load_step, end_record, prediction=self.prediction)
3472+
plan_json_baseline["refresh_minutes"] = self.history_refresh_minutes()
34503473

34513474
# Now try to show what really happened yesterday
34523475
self.charge_limit_best = []
@@ -3577,6 +3600,7 @@ def calculate_yesterday(self):
35773600
plan_html_yesterday, plan_json_yesterday = self.publish_html_plan(
35783601
yesterday_pv_step, yesterday_pv_step, yesterday_load_step, yesterday_load_step, end_record + minutes_now, publish=False, prediction=self.prediction, car_hold_minutes=car_hold_minutes
35793602
)
3603+
plan_json_yesterday["refresh_minutes"] = self.history_refresh_minutes()
35803604
self.forecast_minutes = end_record
35813605

35823606
# Restore state

0 commit comments

Comments
 (0)