Skip to content

Commit a769310

Browse files
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>
1 parent 82bee43 commit a769310

6 files changed

Lines changed: 242 additions & 86 deletions

File tree

‎apps/predbat/output.py‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,6 +1930,8 @@ def import_rate_color(rate):
19301930
raw_plan["timestamp"] = self.now_utc_real.isoformat()
19311931

19321932
if publish:
1933+
# The live plan is republished on every re-plan; the web page's stale check reads this
1934+
raw_plan["refresh_minutes"] = self.calculate_plan_every
19331935
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"})
19341936

19351937
return html, raw_plan
@@ -3184,26 +3186,42 @@ def plan_row_holding_for_car(self, minute_start, minute_end, car_hold_minutes=No
31843186
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)
31853187
return held * 2 >= minute_end - minute_start
31863188

3189+
def history_slot(self, when):
3190+
"""Which plan slot the plan history rebuild at when counts towards, as (date, slot number from midnight).
3191+
3192+
Slots are plan_interval_minutes long, shifted one run (PREDICT_STEP) later: the run at a slot boundary
3193+
writes predbat.cost_today seconds before calculate_yesterday() reads its history back from Home
3194+
Assistant, so the slot just finished is rebuilt on the next run, once that value is recorded. This only
3195+
helps when that next run re-plans; with calculate_plan_every at or above the slot length, re-plans can
3196+
fall on boundaries and the rebuild then happens there.
3197+
"""
3198+
when = when - timedelta(minutes=PREDICT_STEP)
3199+
return when.date(), (when.hour * 60 + when.minute) // self.plan_interval_minutes
3200+
3201+
def history_refresh_minutes(self):
3202+
"""The longest the plan history should go between rebuilds: a slot, the run it waits, and up to one re-plan interval."""
3203+
return self.plan_interval_minutes + PREDICT_STEP + self.calculate_plan_every
3204+
31873205
def calculate_yesterday(self):
31883206
"""
31893207
Calculate the base plan for yesterday
31903208
"""
31913209

3192-
# 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
3193-
if self.savings_last_updated:
3194-
# savings_last_update is a timestamp object, compare age and date
3195-
age = self.now_utc - self.savings_last_updated
3196-
if age < timedelta(minutes=59) and self.savings_last_updated.date() == self.now_utc.date():
3197-
# Less than an hour old and already updated today
3198-
return
3210+
# Recompute once per completed plan slot rather than every iteration: the plan history (the web page's
3211+
# History view) shows each slot from what actually happened, so rebuilding as soon as a slot ends keeps the
3212+
# last finished slot on show - the live Plan view starts at the current slot, so an hourly rebuild left the
3213+
# slots since it in neither view. A day rolling over is a new slot too. Each rebuild takes under a second,
3214+
# and it waits one run past the boundary (see history_slot) so the boundary run's own writes are recorded.
3215+
if self.savings_last_updated and self.history_slot(self.savings_last_updated) == self.history_slot(self.now_utc):
3216+
return
31993217

32003218
# Everything below is anchored on yesterday's recorded cost, so fetch that before doing any of
32013219
# the expensive work - when Home Assistant isn't recording predbat.cost_today there is nothing
32023220
# to compute and the step data and rate scans below would only be thrown away again
32033221
cost_today_data = self.get_history_wrapper(entity_id=self.prefix + ".cost_today", days=2, required=False)
32043222
if not cost_today_data:
32053223
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))
3206-
# Record the attempt so this is retried on the normal hourly cadence rather than every cycle
3224+
# Record the attempt so this is retried in the next slot rather than every cycle
32073225
self.savings_last_updated = self.now_utc
32083226
return
32093227

@@ -3247,7 +3265,7 @@ def calculate_yesterday(self):
32473265
# Assume user might charge at the lowest rate only, for fixed tariff
32483266
# Only use yesterday's rate range (k < end_record) for the threshold to prevent today's rates
32493267
# (which are added progressively as minutes_now increases) from changing the baseline charge
3250-
# windows on each hourly recalculation and causing savings_yesterday to fluctuate.
3268+
# windows on each recalculation and causing savings_yesterday to fluctuate.
32513269
charge_window_best = []
32523270
rate_low = self.compute_rate_low_for_yesterday(past_rates, end_record)
32533271
combine_charge = self.combine_charge_slots
@@ -3438,6 +3456,7 @@ def calculate_yesterday(self):
34383456
self.export_limits_best = []
34393457
self.export_window_best = []
34403458
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)
3459+
plan_json_baseline["refresh_minutes"] = self.history_refresh_minutes()
34413460

34423461
# Now try to show what really happened yesterday
34433462
self.charge_limit_best = []
@@ -3568,6 +3587,7 @@ def calculate_yesterday(self):
35683587
plan_html_yesterday, plan_json_yesterday = self.publish_html_plan(
35693588
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
35703589
)
3590+
plan_json_yesterday["refresh_minutes"] = self.history_refresh_minutes()
35713591
self.forecast_minutes = end_record
35723592

35733593
# Restore state

0 commit comments

Comments
 (0)