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
2532from datetime import datetime , timedelta
2633
2734import pytz
2835
36+ from utils import MinuteArray
37+
2938UTC = 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):
104233def 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