Skip to content

Commit ffd3d67

Browse files
fix(output): apply load_scaling_dynamic and manual_load_adjust to today_remaining (#4511)
* fix(output): apply load_scaling_dynamic and manual_load_adjust to today_remaining (#4496 follow-up) #4506 fixed today_remaining diverging from the plan's own remaining-load total by applying the flat load_scaling factor, but missed two other multipliers step_data_history() (fetch.py) also applies when building the plan's load_minutes_step: load_scaling_dynamic (per-minute - carries saving-session/free-electricity-event scaling, and any per-window override from rates_import_override or the manual API) and manual_load_adjust (additive, per-minute). Confirmed via a live report on the same issue: a user with a 2-hour "power up" (free electricity) event set to load_scaling: 1.5 via the manual API saw today_remaining still diverge from the plan's own total even after updating to the #4506 fix, since only the flat 1.05 base load_scaling was being applied. Replayed their attached debug.yaml directly - their load_scaling_dynamic dict does carry 1.5 for the 2-hour window as expected; load_today_comparison() never looked at it. After this fix, the same replay's plan-vs-sensor ratio drops from a much larger gap to 1.0076, matching the same small residual seen on the original #4506 replay. New tests confirm today_remaining scales with load_scaling_dynamic (uniformly across the day, mirroring the existing load_scaling test) and reflects a single manual_load_adjust entry additively. Both confirmed to fail without this fix and pass with it. * cspell: rephrase comments to avoid a reporter's GitHub handle Follow-up to the previous commit - cspell flagged the handle as an unknown word; reworded rather than adding it to the dictionary. * fix(fetch): reword days_previous_auto log to not imply it ran this cycle Raised alongside the #4496 follow-up investigation: the "days_previous_auto enabled - using weighted-bucket historical load forecast..." log line fires unconditionally every cycle purely because days_previous_auto defaults to True, regardless of whether the weighted-bucket forecast is actually used that cycle. Load ML (or any source that sets load_forecast_only) takes precedence and skips it entirely - fetch_sensor_data() already has its own, correctly conditional "Using weighted-bucket historical load forecast over N days" line that only logs when the fallback genuinely runs. Reworded to describe what's enabled/configured, not what happened, so a Load ML user reading the log isn't misled into thinking both forecast sources are being blended every cycle when they aren't - confirmed via code trace, not just the log wording, that no double-application actually occurs.
1 parent 3050004 commit ffd3d67

3 files changed

Lines changed: 132 additions & 13 deletions

File tree

apps/predbat/fetch.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2366,7 +2366,16 @@ def fetch_config_options(self):
23662366
self.load_forecast_history = self.get_arg("days_previous_auto", True)
23672367
if self.load_forecast_history:
23682368
window_days = min(max(self.days_previous) if self.days_previous else 7, LOAD_FORECAST_HISTORY_MAX_DAYS)
2369-
self.log("days_previous_auto enabled - using weighted-bucket historical load forecast over up to {} days".format(window_days))
2369+
# Config-time log only - describes what's enabled, not what happened this cycle. This
2370+
# runs unconditionally every cycle regardless of whether the weighted-bucket forecast
2371+
# actually gets used: Load ML (or any other source that sets load_forecast_only) takes
2372+
# precedence and skips it entirely (fetch_sensor_data(), guarded by
2373+
# "not self.load_forecast_only"). The "using weighted-bucket..." wording previously
2374+
# here read as if it was happening every cycle regardless, which is what actually gets
2375+
# logged only when the forecast is genuinely used (fetch_sensor_data()'s own "Using
2376+
# weighted-bucket historical load forecast over N days" line) - confusing on a Load ML
2377+
# setup where this fallback is rarely/never actually invoked (#4496 follow-up).
2378+
self.log("days_previous_auto enabled - will fall back to a weighted-bucket historical load forecast over up to {} days if no other load forecast source takes precedence".format(window_days))
23702379
self.max_days_previous = window_days + 1
23712380
elif self.holiday_days_left > 0:
23722381
self.days_previous = [1]

apps/predbat/output.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2588,19 +2588,34 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import
25882588
load_value_pred += forecast_value_pred
25892589
load_value_pred_raw += forecast_value_pred
25902590

2591-
# For FUTURE minutes only, apply load_scaling so the published predicted/adjusted
2592-
# curves (and their today_remaining attribute) match step_data_history() (fetch.py),
2593-
# which the plan itself uses to build load_minutes_step as
2594-
# (value + load_extra) * scale_fixed, where scale_fixed includes load_scaling.
2595-
# Minutes already elapsed today are deliberately left unscaled: load_total_pred_now
2591+
# 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,
2601+
# confirmed against a real follow-up report on issue #4496 where a 1.5x
2602+
# load_scaling_dynamic override for a 2-hour power-up event wasn't reflected in
2603+
# today_remaining at all.
2604+
#
2605+
# Minutes already elapsed today are deliberately left untouched: load_total_pred_now
25962606
# below feeds the actual-vs-predicted divergence ratio, which compares actual
2597-
# consumption against the RAW model, not a load_scaling-corrected one. Previously
2598-
# load_scaling was never applied here at all, so with load_scaling != 1.0 the
2599-
# today_remaining attribute diverged from the plan's own remaining-load total by
2600-
# exactly that factor (issue #4496).
2607+
# consumption against the raw model, not an adjusted one.
26012608
if minute >= minutes_now:
2602-
load_value_pred *= self.load_scaling
2603-
load_value_pred_raw *= self.load_scaling
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
2615+
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
26042619

26052620
# Track (but no longer exclude) periods where import exceeds raw load, assumed to
26062621
# include deliberate battery charging (overnight for example). The house's own load

apps/predbat/tests/test_load_today_comparison.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,99 @@ def _test_load_scaling_applied_to_predicted(my_predbat, failed):
310310
return failed
311311

312312

313+
def _test_load_scaling_dynamic_and_manual_adjust_applied_to_predicted(my_predbat, failed):
314+
"""
315+
Follow-up regression test for issue #4496: the fix in _test_load_scaling_applied_to_predicted
316+
only applied the flat self.load_scaling, but step_data_history() (fetch.py) also applies
317+
self.load_scaling_dynamic (per-minute - saving-session/free-electricity-event scaling, and
318+
any per-window override from rates_import_override/the manual API) and self.manual_load_adjust
319+
(additive, per-minute). Confirmed against a real follow-up user report where a 1.5x
320+
load_scaling_dynamic override for a 2-hour "power up" event wasn't reflected in
321+
today_remaining at all even after the first fix landed.
322+
"""
323+
print(" test: load_scaling_dynamic and manual_load_adjust are applied to the predicted (today_remaining) load curve")
324+
325+
saved = {
326+
"car_charging_hold": my_predbat.car_charging_hold,
327+
"car_charging_energy": my_predbat.car_charging_energy,
328+
"iboost_energy_subtract": my_predbat.iboost_energy_subtract,
329+
"iboost_energy_today": my_predbat.iboost_energy_today,
330+
"base_load": my_predbat.base_load,
331+
"load_forecast_only": my_predbat.load_forecast_only,
332+
"days_previous": my_predbat.days_previous,
333+
"days_previous_weight": my_predbat.days_previous_weight,
334+
"load_minutes_age": my_predbat.load_minutes_age,
335+
"load_scaling": my_predbat.load_scaling,
336+
"load_scaling_dynamic": my_predbat.load_scaling_dynamic,
337+
"manual_load_adjust": my_predbat.manual_load_adjust,
338+
"now_utc": my_predbat.now_utc,
339+
"midnight_utc": my_predbat.midnight_utc,
340+
"minutes_now": my_predbat.minutes_now,
341+
}
342+
343+
try:
344+
my_predbat.car_charging_hold = False
345+
my_predbat.car_charging_energy = None
346+
my_predbat.iboost_energy_subtract = False
347+
my_predbat.iboost_energy_today = None
348+
my_predbat.base_load = 0.0
349+
my_predbat.load_forecast_only = False
350+
my_predbat.days_previous = [1]
351+
my_predbat.days_previous_weight = [1.0]
352+
my_predbat.load_minutes_age = 1
353+
my_predbat.load_scaling = 1.0
354+
355+
midnight_utc = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
356+
minutes_now = 780 # 13:00, well clear of the day boundary
357+
my_predbat.midnight_utc = midnight_utc
358+
my_predbat.now_utc = midnight_utc + timedelta(minutes=minutes_now)
359+
my_predbat.minutes_now = minutes_now
360+
361+
load_minutes = build_cumulative(0.02, 3000) # 0.02 kWh/min -> 0.1 kWh per 5-min bucket
362+
load_forecast = {}
363+
import_minutes = build_cumulative(0.0, 3000)
364+
365+
# Baseline: no dynamic scaling, no manual adjustment
366+
my_predbat.load_scaling_dynamic = {}
367+
my_predbat.manual_load_adjust = {}
368+
my_predbat.load_today_comparison(load_minutes, load_forecast, {}, import_minutes, minutes_now=minutes_now, step=5, save=True)
369+
baseline_remaining = my_predbat.dashboard_values[my_predbat.prefix + ".load_energy_predicted"]["attributes"]["today_remaining"]
370+
371+
# load_scaling_dynamic applied uniformly across every future minute at 2.0x - the whole
372+
# remaining-today total should double, exactly like the flat load_scaling test does
373+
my_predbat.load_scaling_dynamic = {minute: 2.0 for minute in range(minutes_now, 24 * 60, 5)}
374+
my_predbat.load_today_comparison(load_minutes, load_forecast, {}, import_minutes, minutes_now=minutes_now, step=5, save=True)
375+
dynamic_scaled_remaining = my_predbat.dashboard_values[my_predbat.prefix + ".load_energy_predicted"]["attributes"]["today_remaining"]
376+
377+
expected_dynamic = round(baseline_remaining * 2.0, 2)
378+
if abs(dynamic_scaled_remaining - expected_dynamic) > 0.02:
379+
print(" ERROR: today_remaining with load_scaling_dynamic=2.0 across the day should be ~{} (2x baseline {}), got {}".format(expected_dynamic, baseline_remaining, dynamic_scaled_remaining))
380+
failed = True
381+
else:
382+
print(" PASS: today_remaining scales with load_scaling_dynamic ({} -> {} at 2x)".format(baseline_remaining, dynamic_scaled_remaining))
383+
384+
# manual_load_adjust applied additively at a single future minute
385+
my_predbat.load_scaling_dynamic = {}
386+
manual_adjust_minute = minutes_now + 60
387+
manual_adjust_kwh = 6.0
388+
my_predbat.manual_load_adjust = {manual_adjust_minute: manual_adjust_kwh}
389+
my_predbat.load_today_comparison(load_minutes, load_forecast, {}, import_minutes, minutes_now=minutes_now, step=5, save=True)
390+
manual_adjusted_remaining = my_predbat.dashboard_values[my_predbat.prefix + ".load_energy_predicted"]["attributes"]["today_remaining"]
391+
392+
expected_delta = manual_adjust_kwh * 5 / float(my_predbat.plan_interval_minutes)
393+
expected_manual = round(baseline_remaining + expected_delta, 2)
394+
if abs(manual_adjusted_remaining - expected_manual) > 0.02:
395+
print(" ERROR: today_remaining with a manual_load_adjust of {}kWh at minute {} should be ~{} (baseline {} + {:.2f}), got {}".format(manual_adjust_kwh, manual_adjust_minute, expected_manual, baseline_remaining, expected_delta, manual_adjusted_remaining))
396+
failed = True
397+
else:
398+
print(" PASS: today_remaining reflects manual_load_adjust ({} -> {}, +{:.2f}kWh)".format(baseline_remaining, manual_adjusted_remaining, expected_delta))
399+
finally:
400+
for key, value in saved.items():
401+
setattr(my_predbat, key, value)
402+
403+
return failed
404+
405+
313406
# ---------------------------------------------------------------------------
314407
# Entry point
315408
# ---------------------------------------------------------------------------
@@ -320,13 +413,15 @@ def test_load_today_comparison(my_predbat):
320413
Unit tests for load_today_comparison() covering the None-guard fix
321414
for dp2() calls when filtered_today() returns None, the
322415
import-exceeds-load regression (batpred#4154, #2537), and the
323-
load_scaling-not-applied regression (#4496).
416+
load_scaling/load_scaling_dynamic/manual_load_adjust-not-applied
417+
regression (#4496).
324418
"""
325419
failed = False
326420
print("**** Running load_today_comparison tests ****")
327421

328422
failed = _test_none_guard_no_crash(my_predbat, failed)
329423
failed = _test_import_exceeding_load_still_counted(my_predbat, failed) or failed
330424
failed = _test_load_scaling_applied_to_predicted(my_predbat, failed) or failed
425+
failed = _test_load_scaling_dynamic_and_manual_adjust_applied_to_predicted(my_predbat, failed) or failed
331426

332427
return failed

0 commit comments

Comments
 (0)