Skip to content

Commit f408f5d

Browse files
fix(plan): widen forecast_minutes before building yesterday_load_step/yesterday_pv_step
calculate_yesterday() built these step-data dicts via step_data_history() while self.forecast_minutes was still the live plan's normal horizon. step_data_history() only fills offsets up to forecast_minutes + plan_interval_minutes, but the web plan "History" tab needs a full yesterday (24h) plus today-so-far up to minutes_now - so any offset past the live horizon silently read back as zero Load/PV for the rest of the day (reproduced live: forecast_plan_hours=30, Load kWh read 0 from ~06:00 onward every day, self-healing the next day). The widening fix already existed later in the function, just applied too late - after the step-data dicts were already built too small. Fixes #4418
1 parent f2d87f5 commit f408f5d

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

apps/predbat/output.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2870,11 +2870,19 @@ def calculate_yesterday(self):
28702870

28712871
self.log("Calculating data from yesterday for savings calculation")
28722872

2873+
# step_data_history() only fills in offsets up to self.forecast_minutes + plan_interval_minutes.
2874+
# The History view built below needs a full "yesterday" (24h) plus "today so far" up to now, i.e.
2875+
# offsets up to 24*60 + self.minutes_now - widen forecast_minutes before building these step arrays,
2876+
# not just later when the "yesterday" plan_html is rendered, or any offset beyond the live plan's
2877+
# normal horizon silently reads back as zero load/PV for the rest of the day.
2878+
end_record = 24 * 60
2879+
forecast_minutes_before_yesterday_step = self.forecast_minutes
2880+
self.forecast_minutes = max(self.forecast_minutes, end_record + self.minutes_now)
28732881
yesterday_load_step = self.step_data_history(self.load_minutes, 0, forward=False, scale_today=1.0, scale_fixed=1.0, base_offset=24 * 60 + self.minutes_now)
28742882
yesterday_pv_step = self.step_data_history(self.pv_today, 0, forward=False, scale_today=1.0, scale_fixed=1.0, base_offset=24 * 60 + self.minutes_now)
28752883
yesterday_pv_step_zero = self.step_data_history(None, 0, forward=False, scale_today=1.0, scale_fixed=1.0, base_offset=24 * 60 + self.minutes_now)
2884+
self.forecast_minutes = forecast_minutes_before_yesterday_step
28762885
minutes_back = self.minutes_now + 1
2877-
end_record = 24 * 60
28782886

28792887
# Get yesterday's SoC
28802888
try:

apps/predbat/tests/test_calculate_yesterday.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,26 @@ def _get_history_wrapper(entity_id, days=30, required=True, tracked=True):
191191
return _get_history_wrapper
192192

193193

194+
def _make_recording_step_data(pv_today_ref, my_predbat, forecast_minutes_snapshots):
195+
"""Like _make_mock_step_data, but also records self.forecast_minutes at
196+
the moment of each call, so a test can verify it was already widened
197+
before yesterday_load_step/yesterday_pv_step are built (issue #4418:
198+
step_data_history() only fills offsets up to forecast_minutes, so a
199+
late minutes_now needs forecast_minutes widened *before* these calls,
200+
not just later when the "yesterday" plan_html is rendered)."""
201+
202+
def _mock(item, minutes_now, forward, step=5, scale_today=1.0, scale_fixed=1.0, **kwargs):
203+
forecast_minutes_snapshots.append(my_predbat.forecast_minutes)
204+
if item is None:
205+
return {minute: 0.0 for minute in range(0, 24 * 60, 5)}
206+
elif item is pv_today_ref:
207+
return {minute: FLAT_PV_KWH for minute in range(0, 24 * 60, 5)}
208+
else:
209+
return {minute: FLAT_LOAD_KWH for minute in range(0, 24 * 60, 5)}
210+
211+
return _mock
212+
213+
194214
def _apply_mocks(my_predbat, now_utc, cost_value=100.0, soc_value=5.0):
195215
"""Apply all mocks and return the captured-load list."""
196216
captured_load_steps = []
@@ -338,6 +358,57 @@ def _test_basic_no_car(my_predbat, failed):
338358
return failed
339359

340360

361+
def _test_forecast_minutes_widened_before_step_data(my_predbat, failed):
362+
"""Regression test for #4418.
363+
364+
step_data_history() only fills in offsets up to
365+
self.forecast_minutes + plan_interval_minutes. The "History" view needs
366+
a full "yesterday" (24h) plus "today so far" up to minutes_now, i.e.
367+
offsets up to 24*60 + minutes_now. If forecast_minutes is not widened
368+
to at least that *before* yesterday_load_step/yesterday_pv_step are
369+
built, any offset beyond the live plan's normal horizon silently reads
370+
back as zero load/PV for the rest of the day (reproduced live with
371+
forecast_plan_hours=30 i.e. forecast_minutes=1800, minutes_now=910 -
372+
Load kWh read 0 from ~06:00 onward every day in the History tab).
373+
374+
Uses a late minutes_now (910, i.e. 15:10) with a forecast_minutes
375+
(1800) too small to cover 24*60 + minutes_now (2350) unless widened
376+
first, matching the real-world reproduction.
377+
"""
378+
print("calculate_yesterday: Test – forecast_minutes widened before yesterday_load_step/yesterday_pv_step (#4418)")
379+
now_utc = _setup_base(my_predbat, minutes_now=910)
380+
my_predbat.forecast_minutes = 1800
381+
382+
forecast_minutes_snapshots = []
383+
my_predbat.step_data_history = _make_recording_step_data(my_predbat.pv_today, my_predbat, forecast_minutes_snapshots)
384+
my_predbat.get_history_wrapper = _make_history_mock(my_predbat, now_utc)
385+
my_predbat.plan_write_debug = lambda *a, **kw: ("", "{}")
386+
my_predbat.publish_html_plan = lambda *a, **kw: ("", "{}")
387+
original_run_pred = my_predbat.run_prediction
388+
my_predbat.run_prediction = lambda *a, **kw: _make_mock_run_prediction([])(my_predbat, *a, **kw)
389+
390+
my_predbat.calculate_yesterday()
391+
392+
required_minutes = 24 * 60 + 910
393+
if not forecast_minutes_snapshots:
394+
print("ERROR: step_data_history was never called")
395+
failed = True
396+
elif forecast_minutes_snapshots[0] < required_minutes:
397+
print("ERROR: forecast_minutes was only {} at the first step_data_history call, needed >= {} " "(yesterday_load_step/yesterday_pv_step built before widening)".format(forecast_minutes_snapshots[0], required_minutes))
398+
failed = True
399+
elif any(snap < required_minutes for snap in forecast_minutes_snapshots):
400+
print("ERROR: forecast_minutes dropped below {} partway through the step_data_history calls: {}".format(required_minutes, forecast_minutes_snapshots))
401+
failed = True
402+
403+
if my_predbat.forecast_minutes != 1800:
404+
print("ERROR: forecast_minutes was not restored to its original value (expected 1800, got {})".format(my_predbat.forecast_minutes))
405+
failed = True
406+
407+
_restore_methods(my_predbat, original_run_pred)
408+
my_predbat.savings_last_updated = None
409+
return failed
410+
411+
341412
def _test_car_slot_subtraction(my_predbat, failed):
342413
"""Test 3: Car-slot subtraction and car_charging_soc handling.
343414
@@ -1067,6 +1138,7 @@ def test_calculate_yesterday(my_predbat):
10671138

10681139
failed = _test_early_exit(my_predbat, failed)
10691140
failed = _test_basic_no_car(my_predbat, failed)
1141+
failed = _test_forecast_minutes_widened_before_step_data(my_predbat, failed)
10701142
failed = _test_car_slot_subtraction(my_predbat, failed)
10711143
failed = _test_car_slot_from_energy_sensor(my_predbat, failed)
10721144
failed = _test_early_exit_respects_day_rollover(my_predbat, failed)

0 commit comments

Comments
 (0)