Skip to content

Commit 4e564b1

Browse files
fix(output): stop load_energy_predicted's whole-day total drifting through the day (#4519)
load_today_comparison() applied load_scaling/load_scaling_dynamic/manual_load_adjust to future minutes only (#4506, #4511), which correctly fixed today_remaining but also fed load_total_pred - the accumulator the whole-day "today" total is read from - a mix of unscaled past buckets and scaled future ones. As minutes_now advanced, buckets kept flipping from the scaled group to the unscaled one, so the published "today" total (and its chart) drifted by whatever load_scaling wasn't 1.0, even though nothing about the underlying forecast changed. Adds a separate, consistently-scaled accumulator (load_total_pred_day) used only for load_energy_predicted's state/today/today_so_far/today_remaining/results, leaving load_total_pred itself (and the actual-vs-predicted divergence ratio it feeds) untouched.
1 parent c0e2c21 commit 4e564b1

2 files changed

Lines changed: 134 additions & 29 deletions

File tree

apps/predbat/output.py

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2549,6 +2549,7 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import
25492549
"""
25502550
load_total_pred = 0
25512551
load_total_pred_now = 0
2552+
load_total_pred_day = 0
25522553
car_total_pred = 0
25532554
car_total_actual = 0
25542555
car_value_pred = 0
@@ -2557,7 +2558,7 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import
25572558
actual_total_today = 0
25582559
import_ignored_load_pred = 0
25592560
import_ignored_load_actual = 0
2560-
load_predict_stamp = {}
2561+
load_predict_day_stamp = {}
25612562
load_actual_stamp = {}
25622563
load_predict_data = {}
25632564
total_forecast_value_pred = 0
@@ -2588,34 +2589,44 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import
25882589
load_value_pred += forecast_value_pred
25892590
load_value_pred_raw += forecast_value_pred
25902591

2592+
# Consistently-scaled prediction for THIS minute, applying load_scaling,
2593+
# load_scaling_dynamic, and manual_load_adjust the same way whether the minute is
2594+
# in the past or future. This feeds load_total_pred_day below, which is the sole
2595+
# source for load_energy_predicted's state/today/today_so_far/today_remaining/results
2596+
# (batpred#4496 follow-up). Using it consistently across the whole day is what makes
2597+
# that "predicted" total (and its chart) stay flat as minutes_now advances - it isn't
2598+
# meant to reflect what actually happened today, only the model's day-ahead forecast
2599+
# under today's scaling settings, so every bucket needs the same treatment regardless
2600+
# of whether it has elapsed yet.
2601+
manual_adjust_day = 0.0
2602+
if self.manual_load_adjust:
2603+
manual_adjust_day = self.manual_load_adjust.get(minute, 0) * step / float(self.plan_interval_minutes)
2604+
manual_adjust_day = max(manual_adjust_day, -load_value_pred)
2605+
scaling_dynamic_day = self.load_scaling_dynamic.get(minute, 1.0) if self.load_scaling_dynamic else 1.0
2606+
load_value_pred_day = (load_value_pred + manual_adjust_day) * self.load_scaling * scaling_dynamic_day
2607+
25912608
# For FUTURE minutes only, apply load_scaling, load_scaling_dynamic, and
2592-
# manual_load_adjust so the published predicted/adjusted curves (and their
2593-
# today_remaining attribute) match step_data_history() (fetch.py), which the plan
2594-
# itself uses to build load_minutes_step as
2595-
# (value + load_extra) * scaling_dynamic * scale_fixed, where load_extra includes
2596-
# manual_load_adjust, scaling_dynamic is load_scaling_dynamic, and scale_fixed
2597-
# includes the flat load_scaling. load_scaling_dynamic carries saving-session/
2598-
# free-electricity-event scaling as well as any per-window override from
2599-
# rates_import_override/the manual API (e.g. a "power up" event) - a first pass at
2600-
# this fix (#4506) only applied the flat load_scaling and missed both of these,
2609+
# manual_load_adjust so the published today_remaining attribute matches
2610+
# step_data_history() (fetch.py), which the plan itself uses to build
2611+
# load_minutes_step as (value + load_extra) * scaling_dynamic * scale_fixed, where
2612+
# load_extra includes manual_load_adjust, scaling_dynamic is load_scaling_dynamic,
2613+
# and scale_fixed includes the flat load_scaling. load_scaling_dynamic carries
2614+
# saving-session/free-electricity-event scaling as well as any per-window override
2615+
# from rates_import_override/the manual API (e.g. a "power up" event) - a first pass
2616+
# at this fix (#4506) only applied the flat load_scaling and missed both of these,
26012617
# confirmed against a real follow-up report on issue #4496 where a 1.5x
26022618
# load_scaling_dynamic override for a 2-hour power-up event wasn't reflected in
26032619
# today_remaining at all.
26042620
#
2605-
# Minutes already elapsed today are deliberately left untouched: load_total_pred_now
2606-
# below feeds the actual-vs-predicted divergence ratio, which compares actual
2607-
# consumption against the raw model, not an adjusted one.
2621+
# Minutes already elapsed today are deliberately left untouched here: load_total_pred
2622+
# and load_total_pred_now below feed the actual-vs-predicted divergence ratio, which
2623+
# compares actual consumption against the raw model, not an adjusted one.
26082624
if minute >= minutes_now:
2609-
manual_adjust = 0.0
2610-
if self.manual_load_adjust:
2611-
manual_adjust = self.manual_load_adjust.get(minute, 0) * step / float(self.plan_interval_minutes)
2612-
manual_adjust = max(manual_adjust, -load_value_pred)
2613-
load_value_pred += manual_adjust
2614-
load_value_pred_raw += manual_adjust
2625+
load_value_pred += manual_adjust_day
2626+
load_value_pred_raw += manual_adjust_day
26152627

2616-
scaling_dynamic = self.load_scaling_dynamic.get(minute, 1.0) if self.load_scaling_dynamic else 1.0
2617-
load_value_pred *= self.load_scaling * scaling_dynamic
2618-
load_value_pred_raw *= self.load_scaling * scaling_dynamic
2628+
load_value_pred *= self.load_scaling * scaling_dynamic_day
2629+
load_value_pred_raw *= self.load_scaling * scaling_dynamic_day
26192630

26202631
# Track (but no longer exclude) periods where import exceeds raw load, assumed to
26212632
# include deliberate battery charging (overnight for example). The house's own load
@@ -2643,14 +2654,15 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import
26432654
actual_total_today += load_value_pred
26442655

26452656
load_total_pred += load_value_pred
2657+
load_total_pred_day += load_value_pred_day
26462658
total_forecast_value_pred += forecast_value_pred
26472659

26482660
load_predict_data[minute] = load_value_pred
26492661

26502662
# Store for charts
26512663
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * minute)
26522664
stamp = minute_timestamp.strftime(TIME_FORMAT)
2653-
load_predict_stamp[stamp] = dp3(load_total_pred)
2665+
load_predict_day_stamp[stamp] = dp3(load_total_pred_day)
26542666
load_actual_stamp[stamp] = dp3(actual_total_today)
26552667

26562668
# Fetch yesterday's in-day adjustment factor from history
@@ -2753,18 +2765,18 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import
27532765
"icon": "mdi:percent",
27542766
},
27552767
)
2756-
load_so_far = self.filtered_today(load_predict_stamp, stamp=self.now_utc)
2757-
load_today = self.filtered_today(load_predict_stamp)
2768+
load_so_far = self.filtered_today(load_predict_day_stamp, stamp=self.now_utc)
2769+
load_today = self.filtered_today(load_predict_day_stamp)
27582770
load_today_remaining = None
27592771
if (load_so_far is not None) and (load_today is not None):
27602772
load_today_remaining = load_today - load_so_far
27612773

27622774
if save:
27632775
self.dashboard_item(
27642776
self.prefix + ".load_energy_predicted",
2765-
state=dp3(load_total_pred),
2777+
state=dp3(load_total_pred_day),
27662778
attributes={
2767-
"results": self.filtered_times(load_predict_stamp),
2779+
"results": self.filtered_times(load_predict_day_stamp),
27682780
"today": dp2(load_today) if load_today is not None else 0.0,
27692781
"today_so_far": dp2(load_so_far) if load_so_far is not None else 0.0,
27702782
"today_remaining": dp2(load_today_remaining) if load_today_remaining is not None else 0.0,

apps/predbat/tests/test_load_today_comparison.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,97 @@ def _test_load_scaling_dynamic_and_manual_adjust_applied_to_predicted(my_predbat
403403
return failed
404404

405405

406+
def _test_predicted_today_stable_across_the_day(my_predbat, failed):
407+
"""
408+
Follow-up regression test for issue #4496: load_energy_predicted's state/"today" total
409+
(the whole midnight-to-midnight forecast, as opposed to "today_remaining") must not drift
410+
as minutes_now advances through the day when load_scaling != 1.0. The two fixes above
411+
(#4506, #4511) applied load_scaling/load_scaling_dynamic/manual_load_adjust to future
412+
minutes only, correctly fixing today_remaining, but load_total_pred - the same accumulator
413+
the whole-day "today" total was read from - summed those scaled future buckets alongside
414+
unscaled past buckets. As minutes_now advanced, buckets kept flipping from the scaled group
415+
to the unscaled one, so the published "today" total silently shrank (with load_scaling > 1)
416+
or grew (with load_scaling < 1) even though nothing about the underlying forecast changed -
417+
exactly the "prediction drops from 11.5kWh to 11.3kWh by 08:00" regression a user reported
418+
after taking the #4511 fix. This asserts the whole-day total is identical whether computed
419+
at the start of the day or partway through it, given the same underlying load model.
420+
"""
421+
print(" test: load_energy_predicted's whole-day 'today' total is stable as minutes_now advances")
422+
423+
saved = {
424+
"car_charging_hold": my_predbat.car_charging_hold,
425+
"car_charging_energy": my_predbat.car_charging_energy,
426+
"iboost_energy_subtract": my_predbat.iboost_energy_subtract,
427+
"iboost_energy_today": my_predbat.iboost_energy_today,
428+
"base_load": my_predbat.base_load,
429+
"load_forecast_only": my_predbat.load_forecast_only,
430+
"days_previous": my_predbat.days_previous,
431+
"days_previous_weight": my_predbat.days_previous_weight,
432+
"load_minutes_age": my_predbat.load_minutes_age,
433+
"load_scaling": my_predbat.load_scaling,
434+
"load_scaling_dynamic": my_predbat.load_scaling_dynamic,
435+
"manual_load_adjust": my_predbat.manual_load_adjust,
436+
"now_utc": my_predbat.now_utc,
437+
"midnight_utc": my_predbat.midnight_utc,
438+
"minutes_now": my_predbat.minutes_now,
439+
}
440+
441+
try:
442+
my_predbat.car_charging_hold = False
443+
my_predbat.car_charging_energy = None
444+
my_predbat.iboost_energy_subtract = False
445+
my_predbat.iboost_energy_today = None
446+
my_predbat.base_load = 0.0
447+
my_predbat.load_forecast_only = False
448+
my_predbat.days_previous = [1]
449+
my_predbat.days_previous_weight = [1.0]
450+
my_predbat.load_minutes_age = 1
451+
my_predbat.load_scaling = 1.05
452+
my_predbat.load_scaling_dynamic = {}
453+
my_predbat.manual_load_adjust = {}
454+
455+
midnight_utc = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
456+
my_predbat.midnight_utc = midnight_utc
457+
458+
load_minutes = build_cumulative(0.02, 3000) # 0.02 kWh/min -> 0.1 kWh per 5-min bucket
459+
load_forecast = {}
460+
import_minutes = build_cumulative(0.0, 3000)
461+
462+
# Same day, same underlying load model, only minutes_now differs: at midnight (nothing
463+
# elapsed yet) and again mid-afternoon (most of the day elapsed).
464+
today_at_midnight = None
465+
today_mid_afternoon = None
466+
for minutes_now in (0, 900): # 00:00 and 15:00
467+
my_predbat.now_utc = midnight_utc + timedelta(minutes=minutes_now)
468+
my_predbat.minutes_now = minutes_now
469+
my_predbat.load_today_comparison(load_minutes, load_forecast, {}, import_minutes, minutes_now=minutes_now, step=5, save=True)
470+
attrs = my_predbat.dashboard_values[my_predbat.prefix + ".load_energy_predicted"]["attributes"]
471+
state = my_predbat.dashboard_values[my_predbat.prefix + ".load_energy_predicted"]["state"]
472+
if minutes_now == 0:
473+
today_at_midnight = attrs["today"]
474+
state_at_midnight = state
475+
else:
476+
today_mid_afternoon = attrs["today"]
477+
state_mid_afternoon = state
478+
479+
if today_at_midnight <= 0:
480+
print(" ERROR: today total at midnight should be positive for this to be a meaningful test, got {}".format(today_at_midnight))
481+
failed = True
482+
elif abs(today_mid_afternoon - today_at_midnight) > 0.02:
483+
print(" ERROR: 'today' total drifted as minutes_now advanced - midnight {} vs 15:00 {} (load_scaling=1.05 constant throughout)".format(today_at_midnight, today_mid_afternoon))
484+
failed = True
485+
elif abs(state_mid_afternoon - state_at_midnight) > 0.02:
486+
print(" ERROR: state drifted as minutes_now advanced - midnight {} vs 15:00 {} (load_scaling=1.05 constant throughout)".format(state_at_midnight, state_mid_afternoon))
487+
failed = True
488+
else:
489+
print(" PASS: 'today' total and state stayed flat across the day ({} at 00:00, {} at 15:00)".format(today_at_midnight, today_mid_afternoon))
490+
finally:
491+
for key, value in saved.items():
492+
setattr(my_predbat, key, value)
493+
494+
return failed
495+
496+
406497
# ---------------------------------------------------------------------------
407498
# Entry point
408499
# ---------------------------------------------------------------------------
@@ -412,9 +503,10 @@ def test_load_today_comparison(my_predbat):
412503
"""
413504
Unit tests for load_today_comparison() covering the None-guard fix
414505
for dp2() calls when filtered_today() returns None, the
415-
import-exceeds-load regression (batpred#4154, #2537), and the
506+
import-exceeds-load regression (batpred#4154, #2537), the
416507
load_scaling/load_scaling_dynamic/manual_load_adjust-not-applied
417-
regression (#4496).
508+
regression (#4496), and the whole-day "today" total drifting as
509+
minutes_now advances (#4496 follow-up).
418510
"""
419511
failed = False
420512
print("**** Running load_today_comparison tests ****")
@@ -423,5 +515,6 @@ def test_load_today_comparison(my_predbat):
423515
failed = _test_import_exceeding_load_still_counted(my_predbat, failed) or failed
424516
failed = _test_load_scaling_applied_to_predicted(my_predbat, failed) or failed
425517
failed = _test_load_scaling_dynamic_and_manual_adjust_applied_to_predicted(my_predbat, failed) or failed
518+
failed = _test_predicted_today_stable_across_the_day(my_predbat, failed) or failed
426519

427520
return failed

0 commit comments

Comments
 (0)