Skip to content

Commit ea306d3

Browse files
Merge pull request #3784 from amasolov/fix/ml-load-power-clean-increment
Fix ML load inflation: skip clean_incrementing_reverse for power sensors
2 parents 22e4131 + 2de2771 commit ea306d3

3 files changed

Lines changed: 97 additions & 7 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ def minute_data_import_export(self, max_days_previous, now_utc, key, scale=1.0,
605605

606606
return import_today
607607

608-
def minute_data_load(self, now_utc, entity_name, max_days_previous, load_scaling=1.0, required_unit=None, interpolate=False, pad=True):
608+
def minute_data_load(self, now_utc, entity_name, max_days_previous, load_scaling=1.0, required_unit=None, interpolate=False, pad=True, clean_increment=True):
609609
"""
610610
Download one or more entities for load data
611611
"""
@@ -652,7 +652,7 @@ def minute_data_load(self, now_utc, entity_name, max_days_previous, load_scaling
652652
backwards=True,
653653
smoothing=True,
654654
scale=load_scaling,
655-
clean_increment=True,
655+
clean_increment=clean_increment,
656656
accumulate=load_minutes,
657657
required_unit=required_unit,
658658
interpolate=interpolate,
@@ -744,9 +744,8 @@ def fetch_sensor_data(self, save=True):
744744
self.download_ge_data(self.now_utc)
745745

746746
if ("load_power" in self.args) and self.get_arg("load_power_fill_enable", True):
747-
# Use power data to make load data more accurate
748747
self.log("Using load_power data to fill gaps in load_today data")
749-
load_power_data, _ = self.minute_data_load(self.now_utc, "load_power", self.max_days_previous, required_unit="W", load_scaling=1.0, interpolate=True)
748+
load_power_data, _ = self.minute_data_load(self.now_utc, "load_power", self.max_days_previous, required_unit="W", load_scaling=1.0, interpolate=True, clean_increment=False)
750749
self.load_minutes = self.fill_load_from_power(self.load_minutes, load_power_data)
751750
else:
752751
# Load data
@@ -757,9 +756,12 @@ def fetch_sensor_data(self, save=True):
757756
self.load_last_period = (self.load_minutes.get(0, 0) - self.load_minutes.get(PREDICT_STEP, 0)) * 60 / PREDICT_STEP
758757

759758
if ("load_power" in self.args) and self.get_arg("load_power_fill_enable", True):
760-
# Use power data to make load data more accurate
759+
# Use power data to make load data more accurate.
760+
# clean_increment=False: power sensors report instantaneous W, not cumulative kWh.
761+
# clean_incrementing_reverse would distort fluctuating power readings into an
762+
# ever-growing cumulative series, inflating fill_load_from_power gap-fills.
761763
self.log("Using load_power data to fill gaps in load_today data")
762-
load_power_data, _ = self.minute_data_load(self.now_utc, "load_power", self.max_days_previous, required_unit="W", load_scaling=1.0, interpolate=True)
764+
load_power_data, _ = self.minute_data_load(self.now_utc, "load_power", self.max_days_previous, required_unit="W", load_scaling=1.0, interpolate=True, clean_increment=False)
763765
self.load_minutes = self.fill_load_from_power(self.load_minutes, load_power_data)
764766
else:
765767
if self.load_forecast:

‎apps/predbat/load_ml_component.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,10 @@ async def _fetch_load_data(self):
315315

316316
load_power_data = None
317317
if self.get_arg("load_power", default=None, indirect=False) and self.get_arg("load_power_fill_enable", True):
318-
load_power_data, load_minutes_age = self.base.minute_data_load(self.now_utc, "load_power", days_to_fetch, required_unit="W", load_scaling=1.0, interpolate=True, pad=False)
318+
# clean_increment=False: power sensors report instantaneous W, not cumulative kWh.
319+
# clean_incrementing_reverse would distort fluctuating power readings into an
320+
# ever-growing cumulative series, inflating fill_load_from_power gap-fills.
321+
load_power_data, load_minutes_age = self.base.minute_data_load(self.now_utc, "load_power", days_to_fetch, required_unit="W", load_scaling=1.0, interpolate=True, pad=False, clean_increment=False)
319322
load_minutes = self.base.fill_load_from_power(load_minutes, load_power_data)
320323

321324
# Re-read car charging settings dynamically so switch changes are picked up without a restart.

‎apps/predbat/tests/test_fill_load_from_power.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# Add the apps/predbat directory to the path
99
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "apps", "predbat"))
1010

11+
from datetime import datetime, timezone
1112
from fetch import Fetch
1213
from utils import dp4
1314

@@ -20,6 +21,7 @@ def __init__(self):
2021
self.log_messages = []
2122
self.forecast_minutes = 24 * 60 # 24 hours
2223
self.plan_interval_minutes = 30
24+
self.now_utc = datetime.now(timezone.utc)
2325

2426
def log(self, message):
2527
"""Capture log messages"""
@@ -362,6 +364,7 @@ def run_all_tests(my_predbat=None):
362364
test_fill_load_from_power_zero_load()
363365
test_fill_load_from_power_backwards_time()
364366
test_fill_load_from_power_data_extends_beyond_load()
367+
test_fill_load_from_power_distorted_power_inflation()
365368

366369
print("\n" + "=" * 60)
367370
print("✅ ALL TESTS PASSED")
@@ -382,6 +385,88 @@ def run_all_tests(my_predbat=None):
382385
return 1 # Return 1 for error
383386

384387

388+
def test_fill_load_from_power_distorted_power_inflation():
389+
"""
390+
Regression test for issue #3692: ML load actual ~2x real consumption.
391+
392+
When load_power data is loaded via minute_data_load with clean_increment=True,
393+
clean_incrementing_reverse treats the fluctuating instantaneous power (W) values
394+
as a cumulative sensor and accumulates all positive increments into an ever-growing
395+
series. fill_load_from_power Phase 1 (zero-period filling) then integrates these
396+
inflated values, bumping all more-recent cumulative entries by the total inflated
397+
fill. Phase 2 preserves these bumped endpoints, so the overall energy
398+
(data[0] - data[max]) is inflated even though per-window redistribution is correct.
399+
400+
This test verifies that:
401+
1. clean_incrementing_reverse grossly distorts non-cumulative power data
402+
2. fill_load_from_power with distorted power inflates the total cumulative energy
403+
3. fill_load_from_power with correct power produces a reasonable total
404+
"""
405+
print("\n=== Test 8: Power data distortion causes load inflation (issue #3692) ===")
406+
407+
from utils import clean_incrementing_reverse
408+
409+
fetch = TestFetch()
410+
411+
# Simulate 240 minutes (4 hours) of load data with a 60-minute flat (zero) period
412+
# from minute 60 to minute 120. Backwards format: minute 0 is now (highest value).
413+
load_minutes = {}
414+
# Minute 0-59: smooth ramp (0.05 kWh/min consumed)
415+
for minute in range(0, 60):
416+
load_minutes[minute] = 15.0 - minute * 0.05
417+
# Minute 60-120: flat at 12.0 kWh (sensor didn't increment - zero period)
418+
for minute in range(60, 121):
419+
load_minutes[minute] = 12.0
420+
# Minute 121-240: smooth ramp
421+
for minute in range(121, 241):
422+
load_minutes[minute] = 12.0 - (minute - 120) * 0.05
423+
424+
total_original = load_minutes[0] - load_minutes[240]
425+
426+
# Realistic fluctuating power data (~600W, varying 300-900W) -
427+
# this is what minute_data_load should return when clean_increment=False
428+
correct_power_data = {}
429+
for minute in range(241):
430+
correct_power_data[minute] = 600.0 + 100.0 * ((minute % 7) - 3)
431+
432+
# Simulate the bug: clean_incrementing_reverse on non-cumulative power data
433+
distorted_power_data = clean_incrementing_reverse(correct_power_data)
434+
435+
# Verify distortion magnitude
436+
max_correct = max(correct_power_data.values())
437+
max_distorted = max(distorted_power_data.values())
438+
print(f" Max correct power: {max_correct:.0f}W, Max distorted: {max_distorted:.0f}W")
439+
assert max_distorted > max_correct * 10, f"Distorted power should be >>10x correct; got {max_distorted:.0f} vs {max_correct:.0f}"
440+
441+
# Run fill_load_from_power with CORRECT power data
442+
result_correct = fetch.fill_load_from_power(load_minutes.copy(), correct_power_data)
443+
444+
# Run fill_load_from_power with DISTORTED power data (the bug)
445+
result_distorted = fetch.fill_load_from_power(load_minutes.copy(), distorted_power_data)
446+
447+
# Check total energy (data[0] - data[max]): the Phase 1 bump inflates data[0]
448+
total_correct = result_correct[0] - result_correct.get(240, result_correct.get(239, 0))
449+
total_distorted = result_distorted[0] - result_distorted.get(240, result_distorted.get(239, 0))
450+
451+
print(f" Total energy original: {total_original:.2f} kWh")
452+
print(f" Total energy (correct power): {total_correct:.2f} kWh")
453+
print(f" Total energy (distorted power): {total_distorted:.2f} kWh")
454+
print(f" Inflation ratio (distorted/orig): {total_distorted / total_original:.2f}x")
455+
456+
# Correct power fill should add a reasonable amount (60 min at ~600W = ~0.6 kWh)
457+
correct_fill_excess = total_correct - total_original
458+
assert correct_fill_excess < 2.0, f"Correct fill should add <2 kWh over original; added {correct_fill_excess:.2f}"
459+
460+
# Distorted power fill should cause significant inflation (the bug)
461+
distorted_fill_excess = total_distorted - total_original
462+
assert distorted_fill_excess > 2.0, f"Distorted fill should inflate by >2 kWh; only added {distorted_fill_excess:.2f}. " "If this assertion fails, Phase 2 may be fully correcting Phase 1 and the bug " "mechanism is different than expected."
463+
464+
# The distorted total should be noticeably larger than the correct total
465+
assert total_distorted > total_correct * 1.2, f"Distorted total ({total_distorted:.2f}) should be >1.2x correct ({total_correct:.2f})"
466+
467+
print(" PASS: Distorted power data causes measurable cumulative inflation")
468+
469+
385470
if __name__ == "__main__":
386471
success = run_all_tests()
387472
sys.exit(0 if success else 1)

0 commit comments

Comments
 (0)