Skip to content

Commit 4d2f7ea

Browse files
committed
fix: iBoost demand grid covers the fetchable horizon for tariff compare
The tariff compare raises forecast_minutes to 48h after fetch_sensor_data has run, so a demand grid built over the live forecast_minutes read as zero for the extended horizon and compared tariffs saw different iBoost demand. The grid now covers the whole fetchable horizon (forecast_days + 1 days) and the raw data extent is recorded separately (iboost_forecast_extent) so the fill pass stays clamped to real data rather than the grid end. Test helpers reset the extent alongside the forecast they inject. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QiqN8TJpbXRsSkph6yNSx
1 parent b859553 commit 4d2f7ea

4 files changed

Lines changed: 33 additions & 7 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,7 @@ def fetch_sensor_data(self, save=True):
10211021

10221022
# iBoost hot water demand forecast and tank state of charge
10231023
self.iboost_forecast = {}
1024+
self.iboost_forecast_extent = None
10241025
self.iboost_tank_soc_percent = None
10251026
if self.iboost_enable:
10261027
self.iboost_forecast = self.fetch_iboost_forecast()
@@ -2978,15 +2979,19 @@ def fetch_iboost_forecast(self):
29782979
return {}
29792980

29802981
# Convert the cumulative series into demand per plan interval, aligned to the same interval
2981-
# grid the planner books slots on. Per-minute positive deltas are taken per source (the
2982-
# same reading get_from_incrementing gives the load forecast) and only within that source's
2983-
# own raw extent, so a series that ends or resets never books phantom demand from
2984-
# minute_data's back-fill and one source's tail cannot mask another's draw. Minutes already
2985-
# elapsed in the current interval are skipped: any draw there is in the tank SoC reading.
2982+
# grid the planner books slots on. The grid covers the whole fetchable horizon (not just
2983+
# forecast_minutes) so a planning horizon raised after the fetch - the tariff compare sets
2984+
# forecast_minutes to 48h after fetching - still finds the demand. Per-minute positive
2985+
# deltas are taken per source (the same reading get_from_incrementing gives the load
2986+
# forecast) and only within that source's own raw extent, so a series that ends or resets
2987+
# never books phantom demand from minute_data's back-fill and one source's tail cannot
2988+
# mask another's draw. Minutes already elapsed in the current interval are skipped: any
2989+
# draw there is in the tank SoC reading.
29862990
demand = {}
29872991
total = 0.0
29882992
start_minute = int(self.minutes_now / self.plan_interval_minutes) * self.plan_interval_minutes
2989-
for minute in range(start_minute, start_minute + self.forecast_minutes, self.plan_interval_minutes):
2993+
grid_end = max(start_minute + self.forecast_minutes, (self.forecast_days + 1) * 24 * 60)
2994+
for minute in range(start_minute, grid_end, self.plan_interval_minutes):
29902995
kwh = 0.0
29912996
for _, forecast, first_minute, last_minute in sources:
29922997
for offset in range(self.plan_interval_minutes):
@@ -3003,6 +3008,7 @@ def fetch_iboost_forecast(self):
30033008
self.log("Warn: iBoost demand forecast contains no future demand, using the legacy iBoost smart plan")
30043009
return {}
30053010

3011+
self.iboost_forecast_extent = last_data_minute
30063012
self.log("iBoost demand forecast loaded: {} kWh over {} intervals of {} minutes from minute {}".format(dp2(total), len(demand), self.plan_interval_minutes, start_minute))
30073013
return demand
30083014

‎apps/predbat/plan.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5513,7 +5513,7 @@ def plan_iboost_forecast(self):
55135513
# seen by later fill slots only counts energy that survives.
55145514
fill_threshold = self.iboost_fill_rate_threshold
55155515
if fill_threshold > -99.0:
5516-
forecast_extent = max(self.iboost_forecast.keys()) + interval
5516+
forecast_extent = self.iboost_forecast_extent if self.iboost_forecast_extent is not None else max(self.iboost_forecast.keys()) + interval
55175517
levels_boosted, levels_after = self.iboost_tank_trajectory(stored_start, capacity, demand, boost)
55185518
day_usage = self.iboost_forecast_day_usage(interval_starts, boost, total_days)
55195519
for slot_n in range(num_intervals):

‎apps/predbat/predbat.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,7 @@ def reset(self):
608608
self.iboost_rate_threshold_export = 9999
609609
self.iboost_plan = []
610610
self.iboost_forecast = {}
611+
self.iboost_forecast_extent = None
611612
self.iboost_tank_capacity = 10.0
612613
self.iboost_tank_reserve = 0.0
613614
self.iboost_tank_soc_percent = None

‎apps/predbat/tests/test_iboost.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def restore_iboost_state(my_predbat):
7676
my_predbat.iboost_tank_reserve = my_predbat.get_arg("iboost_tank_reserve")
7777
my_predbat.iboost_fill_rate_threshold = my_predbat.get_arg("iboost_fill_rate_threshold")
7878
my_predbat.iboost_forecast = {}
79+
my_predbat.iboost_forecast_extent = None
7980
my_predbat.iboost_tank_soc_percent = None
8081
my_predbat.minutes_now = 12 * 60
8182

@@ -313,6 +314,7 @@ def run_iboost_fetch_test(test_name, my_predbat, config, states, expect_demand=N
313314
del my_predbat.args[key]
314315
for entity_id in states:
315316
del my_predbat.ha_interface.dummy_items[entity_id]
317+
my_predbat.iboost_forecast_extent = None
316318
if minutes_now is not None:
317319
my_predbat.minutes_now = 12 * 60
318320

@@ -346,6 +348,7 @@ def run_iboost_forecast_plan_test(
346348
my_predbat.iboost_tank_soc_percent = tank_soc_percent
347349
my_predbat.iboost_fill_rate_threshold = fill_rate_threshold
348350
my_predbat.iboost_forecast = forecast
351+
my_predbat.iboost_forecast_extent = None
349352
saved_gas = None
350353
if gas_rate is not None:
351354
saved_gas = (my_predbat.iboost_gas, my_predbat.iboost_gas_scale, my_predbat.rate_gas)
@@ -409,6 +412,22 @@ def run_iboost_forecast_test_cases(my_predbat):
409412
expect_demand={780: 1.0, 870: 1.0},
410413
expect_total=2.0,
411414
)
415+
# The demand grid spans the whole fetchable horizon (the tariff compare raises
416+
# forecast_minutes after the fetch) and the raw data extent is recorded for the fill clamp
417+
print("**** Running Test: iboost_fetch_wide_grid ****")
418+
my_predbat.args["iboost_forecast"] = ["sensor.hot_water_demand$results"]
419+
my_predbat.ha_interface.set_state("sensor.hot_water_demand", "ok", attributes={"results": forecast_attribute})
420+
demand = my_predbat.fetch_iboost_forecast()
421+
if max(demand) < (my_predbat.forecast_days + 1) * 24 * 60 - my_predbat.plan_interval_minutes:
422+
print("ERROR: iboost_fetch_wide_grid demand grid ends at {} which does not cover the fetchable horizon".format(max(demand)))
423+
failed = True
424+
if my_predbat.iboost_forecast_extent != 900:
425+
print("ERROR: iboost_fetch_wide_grid forecast extent should be 900 got {}".format(my_predbat.iboost_forecast_extent))
426+
failed = True
427+
del my_predbat.args["iboost_forecast"]
428+
del my_predbat.ha_interface.dummy_items["sensor.hot_water_demand"]
429+
my_predbat.iboost_forecast_extent = None
430+
412431
failed |= run_iboost_fetch_test(
413432
"iboost_fetch_scaling",
414433
my_predbat,

0 commit comments

Comments
 (0)