Skip to content

Commit cb1c2b1

Browse files
fix(load): stop zeroing genuine load when import exceeds it for a bucket (#4471)
load_today_comparison() zeroed a whole 5-minute bucket's house load out of both the actual and predicted totals whenever grid import for that bucket was >= the raw load, intended to exclude deliberate battery charging - but the house's own consumption during that minute still happened regardless of how much extra was imported to charge the battery, so only the import beyond the load should have been excluded, not the load itself. On any install that routinely grid-charges overnight (pulling more from the grid than the house draws), this silently dropped a large chunk of real consumption from both the Actual and Predicted in-day-adjustment totals, and made Predicted drift throughout the day as which buckets tripped the filter shifted with the charge window. Fixes batpred#4154, #2537. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f309bc8 commit cb1c2b1

2 files changed

Lines changed: 142 additions & 6 deletions

File tree

apps/predbat/output.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2587,14 +2587,19 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import
25872587
load_value_pred += forecast_value_pred
25882588
load_value_pred_raw += forecast_value_pred
25892589

2590-
# Ignore periods of import as assumed to be deliberate (battery charging periods overnight for example)
2590+
# Track (but no longer exclude) periods where import exceeds raw load, assumed to
2591+
# include deliberate battery charging (overnight for example). The house's own load
2592+
# during that minute was still genuinely consumed regardless of how much extra was
2593+
# imported to charge the battery, so it stays in the actual/predicted totals - only
2594+
# the import beyond that load was going to the battery, not the load itself.
2595+
# Previously this zeroed the whole bucket's load out of both totals, which silently
2596+
# dropped real consumption on any install that routinely grid-charges overnight
2597+
# (batpred#4154, #2537).
25912598
car_value_actual = load_value_today_raw - load_value_today
25922599
car_value_pred = load_value_pred_raw - load_value_pred
25932600
if minute < minutes_now and import_value_today >= load_value_today_raw:
2594-
import_ignored_load_actual += load_value_today
2595-
load_value_today = 0
2596-
import_ignored_load_pred += load_value_pred
2597-
load_value_pred = 0
2601+
import_ignored_load_actual += import_value_today - load_value_today_raw
2602+
import_ignored_load_pred += import_value_today - load_value_today_raw
25982603

25992604
# Only count totals until now
26002605
if minute < minutes_now:

apps/predbat/tests/test_load_today_comparison.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,38 @@
2020
TypeError: type NoneType doesn't define __round__ method
2121
This test verifies the guard prevents the crash and the dashboard
2222
attributes are published as numeric values (0) rather than None.
23+
2. Import-exceeds-load regression (batpred#4154, #2537): when grid import for
24+
a bucket is >= that bucket's raw house load (e.g. overnight battery
25+
charging pulling more than the house is drawing), the house's own genuine
26+
load must still be counted in the actual/predicted totals - only the
27+
import beyond the load was going to the battery. Previously the whole
28+
bucket's load was zeroed out of both totals whenever this happened.
2329
"""
2430

31+
import re
2532
from datetime import datetime, timedelta
2633

2734
import pytz
2835

36+
from utils import MinuteArray
37+
2938
UTC = pytz.UTC
3039

3140

41+
def build_cumulative(per_minute, size):
42+
"""Build a backwards cumulative MinuteArray with a constant per-minute increment.
43+
44+
get_from_incrementing(data, i) == per_minute for every in-range i, so any 5-minute
45+
(or other step) window sums to per_minute * step regardless of exactly which indices
46+
a caller's own offset convention picks.
47+
"""
48+
data = {}
49+
data[size - 1] = 0.0
50+
for i in range(size - 2, -1, -1):
51+
data[i] = data[i + 1] + per_minute
52+
return MinuteArray(data, size)
53+
54+
3255
# ---------------------------------------------------------------------------
3356
# Helpers
3457
# ---------------------------------------------------------------------------
@@ -96,6 +119,112 @@ def _test_none_guard_no_crash(my_predbat, failed):
96119
return failed
97120

98121

122+
def _test_import_exceeding_load_still_counted(my_predbat, failed):
123+
"""
124+
Verify load genuinely consumed during a heavy-import (e.g. overnight charging) bucket
125+
is still counted in the "so far" actual/predicted totals, not zeroed just because grid
126+
import for that bucket was >= the raw house load.
127+
128+
Asserted via the function's own diagnostic log line rather than the published dashboard
129+
states, since those blend "so far" with a predicted-for-the-rest-of-today tail and are not
130+
a clean, minimal readout of the specific total this fix changes.
131+
"""
132+
print(" test: import >= raw load no longer zeros genuine consumption")
133+
134+
# Save state that will be mutated
135+
saved = {
136+
"car_charging_hold": my_predbat.car_charging_hold,
137+
"car_charging_energy": my_predbat.car_charging_energy,
138+
"iboost_energy_subtract": my_predbat.iboost_energy_subtract,
139+
"iboost_energy_today": my_predbat.iboost_energy_today,
140+
"base_load": my_predbat.base_load,
141+
"load_forecast_only": my_predbat.load_forecast_only,
142+
"days_previous": my_predbat.days_previous,
143+
"days_previous_weight": my_predbat.days_previous_weight,
144+
"load_minutes_age": my_predbat.load_minutes_age,
145+
"now_utc": my_predbat.now_utc,
146+
"midnight_utc": my_predbat.midnight_utc,
147+
"minutes_now": my_predbat.minutes_now,
148+
"log": my_predbat.log,
149+
}
150+
151+
captured = []
152+
my_predbat.log = lambda msg, quiet=True: captured.append(msg)
153+
154+
try:
155+
my_predbat.car_charging_hold = False
156+
my_predbat.car_charging_energy = None
157+
my_predbat.iboost_energy_subtract = False
158+
my_predbat.iboost_energy_today = None
159+
my_predbat.base_load = 0.0
160+
my_predbat.load_forecast_only = False
161+
my_predbat.days_previous = [1]
162+
my_predbat.days_previous_weight = [1.0]
163+
my_predbat.load_minutes_age = 1
164+
165+
midnight_utc = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
166+
minutes_now = 17 # not on a step=5 boundary, so "so far" cleanly covers 4 whole buckets
167+
my_predbat.midnight_utc = midnight_utc
168+
my_predbat.now_utc = midnight_utc + timedelta(minutes=minutes_now)
169+
my_predbat.minutes_now = minutes_now
170+
171+
# Import: 0.1 kWh/min -> 0.5 kWh per 5-minute bucket
172+
import_minutes = build_cumulative(0.1, 3000)
173+
# House load: 0.05 kWh/min -> 0.25 kWh per 5-minute bucket, well below import,
174+
# so every "today so far" bucket trips the import >= raw-load condition.
175+
load_minutes = build_cumulative(0.05, 3000)
176+
load_forecast = {}
177+
178+
my_predbat.load_today_comparison(load_minutes, load_forecast, {}, import_minutes, minutes_now=minutes_now, step=5, save=True)
179+
180+
expected_load = 0.25 * 4 # 4 whole buckets (minute 0,5,10,15) fall inside "so far"
181+
expected_ignored = (0.5 - 0.25) * 4 # excess import only, not the whole bucket
182+
183+
actual_line = next((m for m in captured if m.startswith("Today's actual load so far")), None)
184+
predicted_line = next((m for m in captured if m.startswith("Today's predicted so far")), None)
185+
186+
if actual_line is None or predicted_line is None:
187+
print(" ERROR: expected log lines not found - captured: {}".format(captured))
188+
failed = True
189+
else:
190+
actual_load = float(re.search(r"so far ([\d.]+)kWh", actual_line).group(1))
191+
actual_ignored = float(re.search(r"([\d.]+)kWh import ignored", actual_line).group(1))
192+
predicted_load = float(re.search(r"so far ([\d.]+)kWh", predicted_line).group(1))
193+
predicted_ignored = float(re.search(r"([\d.]+)kWh import ignored", predicted_line).group(1))
194+
195+
if abs(actual_load - expected_load) > 0.01:
196+
print(" ERROR: actual load so far = {} (expected ~{})".format(actual_load, expected_load))
197+
failed = True
198+
if abs(actual_ignored - expected_ignored) > 0.01:
199+
print(" ERROR: actual import ignored = {} (expected ~{})".format(actual_ignored, expected_ignored))
200+
failed = True
201+
if abs(predicted_load - expected_load) > 0.01:
202+
print(" ERROR: predicted load so far = {} (expected ~{})".format(predicted_load, expected_load))
203+
failed = True
204+
if abs(predicted_ignored - expected_ignored) > 0.01:
205+
print(" ERROR: predicted import ignored = {} (expected ~{})".format(predicted_ignored, expected_ignored))
206+
failed = True
207+
208+
if not failed:
209+
print(" PASS: genuine load during heavy-import buckets is counted, not zeroed ({:.2f}kWh, {:.2f}kWh excess ignored)".format(expected_load, expected_ignored))
210+
finally:
211+
my_predbat.car_charging_hold = saved["car_charging_hold"]
212+
my_predbat.car_charging_energy = saved["car_charging_energy"]
213+
my_predbat.iboost_energy_subtract = saved["iboost_energy_subtract"]
214+
my_predbat.iboost_energy_today = saved["iboost_energy_today"]
215+
my_predbat.base_load = saved["base_load"]
216+
my_predbat.load_forecast_only = saved["load_forecast_only"]
217+
my_predbat.days_previous = saved["days_previous"]
218+
my_predbat.days_previous_weight = saved["days_previous_weight"]
219+
my_predbat.load_minutes_age = saved["load_minutes_age"]
220+
my_predbat.now_utc = saved["now_utc"]
221+
my_predbat.midnight_utc = saved["midnight_utc"]
222+
my_predbat.minutes_now = saved["minutes_now"]
223+
my_predbat.log = saved["log"]
224+
225+
return failed
226+
227+
99228
# ---------------------------------------------------------------------------
100229
# Entry point
101230
# ---------------------------------------------------------------------------
@@ -104,11 +233,13 @@ def _test_none_guard_no_crash(my_predbat, failed):
104233
def test_load_today_comparison(my_predbat):
105234
"""
106235
Unit tests for load_today_comparison() covering the None-guard fix
107-
for dp2() calls when filtered_today() returns None.
236+
for dp2() calls when filtered_today() returns None, and the
237+
import-exceeds-load regression (batpred#4154, #2537).
108238
"""
109239
failed = False
110240
print("**** Running load_today_comparison tests ****")
111241

112242
failed = _test_none_guard_no_crash(my_predbat, failed)
243+
failed = _test_import_exceeding_load_still_counted(my_predbat, failed) or failed
113244

114245
return failed

0 commit comments

Comments
 (0)