Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
62e8603
fix: iBoost smart duplicate-slot guard tested a stale loop variable
claude Sep 16, 2026
990dd38
feat: iBoost demand forecast configuration and ingestion
claude Aug 25, 2026
62cdb1f
feat: demand-forecast-driven iBoost planning (plan_iboost_forecast)
claude Aug 25, 2026
307ef0c
docs: iBoost demand forecast configuration
claude Aug 25, 2026
83b2171
feat: iBoost fill rate threshold for the forecast planner
claude Aug 25, 2026
9eb40ae
style: collapse iBoost forecast plan log line per Black
claude Aug 25, 2026
f7289a4
fix: iBoost forecast planner review findings
claude Aug 26, 2026
fe32aa5
test: update iboost_smart2/3 goldens for the duplicate-slot guard fix
claude Sep 16, 2026
1fe2db6
fix: address iBoost forecast planner review findings (PR #4756)
claude Sep 16, 2026
638c6d7
fix: address iBoost forecast ingestion review findings (PR #4756)
claude Sep 16, 2026
8f321cd
fix: legacy iBoost smart plan could emit overlapping slots off-grid
claude Sep 16, 2026
337bbd0
test: stop the iBoost suites leaking shared-fixture state (GH#5079 cl…
claude Sep 16, 2026
b18a1ab
docs: clarify iBoost forecast activation modes, min_length and fill g…
claude Sep 16, 2026
b859553
test: rename off-grid overlap test for CSpell
claude Sep 16, 2026
4d2f7ea
fix: iBoost demand grid covers the fetchable horizon for tariff compare
claude Sep 16, 2026
b2a6ded
fix: validate forecast items before they extend the data extent
claude Sep 16, 2026
666c421
style: Black formatting for the rate-state attribute list
claude Sep 16, 2026
e92fa46
Merge remote-tracking branch 'upstream/main' into claude/iboost-deman…
claude Sep 16, 2026
826e1bb
test: correct the smart2/3 golden explanation, cover the fill clamp, …
claude Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,40 @@
"enable": "iboost_enable",
"default": 0.0,
},
{
"name": "iboost_tank_capacity",
"friendly_name": "iBoost tank capacity",
"type": "input_number",
"min": 0,
"max": 50,
"step": 0.1,
"unit": "kWh",
"enable": "iboost_enable",
"default": 10.0,
},
{
"name": "iboost_tank_reserve",
"friendly_name": "iBoost tank reserve",
"type": "input_number",
"min": 0,
"max": 50,
"step": 0.1,
"unit": "kWh",
"enable": "iboost_enable",
"default": 0.0,
},
{
"name": "iboost_fill_rate_threshold",
"friendly_name": "iBoost fill tank at or below rate",
"type": "input_number",
"min": -99,
"max": 1000,
"step": 0.1,
"unit": "p/kWh",
"icon": "mdi:currency-usd",
"enable": "iboost_enable",
"default": -99.0,
},
{
"name": "iboost_max_power",
"friendly_name": "iBoost max power",
Expand Down Expand Up @@ -2802,6 +2836,12 @@
"import_export_scaling": {"type": "float"},
"export_triggers": {"type": "dict_list"},
"iboost_energy_today": {"type": "sensor", "sensor_type": "float"},
"iboost_forecast": {
"type": "sensor_list",
"sensor_type": "dict|list",
},
"iboost_forecast_scaling": {"type": "float"},
"iboost_tank_soc": {"type": "sensor", "sensor_type": "float"},
"metric_octopus_gas": {"type": "sensor", "sensor_type": "float"},
"rates_gas": {"type": "dict_list"},
"futurerate_url": {"type": "string", "empty": False},
Expand Down
215 changes: 169 additions & 46 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,23 @@ def fetch_sensor_data(self, save=True):
self.iboost_today = dp2(abs(self.iboost_energy_today[0] - self.iboost_energy_today[self.minutes_now]))
self.log("iBoost energy today from sensor reads {} kWh".format(self.iboost_today))

# iBoost hot water demand forecast and tank state of charge
self.iboost_forecast = {}
self.iboost_forecast_extent = None
self.iboost_tank_soc_percent = None
if self.iboost_enable:
self.iboost_forecast = self.fetch_iboost_forecast()
if self.iboost_forecast and ("iboost_tank_soc" in self.args):
tank_soc = self.get_arg("iboost_tank_soc", None)
try:
self.iboost_tank_soc_percent = float(tank_soc)
except (ValueError, TypeError):
# An unreadable SoC sensor must not be treated as an empty tank: provisioning
# the whole forecast can book far more than the legacy iboost_max_energy
# budget, so fall back to the legacy plan for this cycle instead
self.log("Warn: Unable to read iboost_tank_soc (value {}), using the legacy iBoost smart plan for this cycle".format(tank_soc))
self.iboost_forecast = {}

# Fetch ML forecast if enabled
load_ml_forecast = {}
if self.get_arg("load_ml_enable", False) and self.get_arg("load_ml_source", False):
Expand Down Expand Up @@ -2882,6 +2899,86 @@ def apply_load_ml_forecast_history(self, now_utc, ml_forecast=None):
if minute < self.minutes_now:
self.load_forecast[minute] = value

def fetch_cumulative_forecasts(self, arg_name, name, scale=1.0):
"""
Fetch the cumulative kWh forecast series configured under arg_name in apps.yaml.

Each entry points to a sensor (optionally sensor$attribute) whose state or attribute
holds either a dict of ISO timestamp -> kWh or a list of {last_updated, energy}
entries. Returns a list of (entity_id, forecast, first_minute, last_minute) tuples,
one per source that yielded data: forecast is the minute-resolution cumulative dict
from minute_data and first/last_minute bound that source's own raw timestamps (needed
because minute_data back-fills its output beyond the data). Shared by the extra load
forecast and the iBoost demand forecast so both read the same formats.
"""
sources = []
entity_ids = self.get_arg(arg_name, indirect=False)
if isinstance(entity_ids, str):
entity_ids = [entity_ids]

for entity_id in entity_ids or []:
if not entity_id:
self.log("Warn: Unable to fetch {} data, check your {} setting in apps.yaml".format(name, arg_name))
continue

attribute = None
if "$" in entity_id:
entity_id, attribute = entity_id.split("$")
try:
self.log("Loading {} from {}, attribute {}".format(name, entity_id, attribute))
data = self.get_state_wrapper(entity_id=entity_id, attribute=attribute)
except (ValueError, TypeError) as e:
self.log("Warn: Unable to fetch {} data from sensor {}, exception {}".format(name, entity_id, e))
data = None

# Convert format from dict to array
if isinstance(data, dict):
data_array = []
for key, value in data.items():
data_array.append({"energy": value, "last_updated": key})
data = data_array
if (data is not None) and (not isinstance(data, list)):
self.log("Warn: {} data from {} is not in a supported format. Skipping forecast source.".format(name, entity_id))
data = None

# Track the extent of the raw data; minute_data back-fills its output past the last
# data point, so consumers judging staleness or taking deltas need the raw bounds.
# Only items minute_data will accept count: a parseable timestamp AND a numeric
# energy value, so a junk-valued entry (e.g. a template sensor point still
# 'unavailable') cannot extend the extent past the usable data.
first_minute = None
last_minute = None
for item in data or []:
try:
float(item["energy"])
item_minute = int((str2time(item["last_updated"]) - self.midnight_utc).total_seconds() / 60)
except (ValueError, TypeError, KeyError):
continue
if (last_minute is None) or (item_minute > last_minute):
last_minute = item_minute
if (first_minute is None) or (item_minute < first_minute):
first_minute = item_minute

forecast, _ = minute_data(
data,
self.forecast_days + 1,
self.midnight_utc,
"energy",
"last_updated",
backwards=False,
clean_increment=False,
smoothing=True,
divide_by=1.0,
scale=scale,
required_unit="kWh",
)

if forecast and (last_minute is not None):
sources.append((entity_id, forecast, first_minute, last_minute))
else:
self.log("Warn: Unable to load the {} from {}. Skipping forecast source.".format(name, entity_id))
return sources

def fetch_extra_load_forecast(self, now_utc, ml_forecast=None):
"""
Fetch extra load forecast, this is future load data
Expand All @@ -2894,52 +2991,9 @@ def fetch_extra_load_forecast(self, now_utc, ml_forecast=None):
load_forecast_array.append(ml_forecast)

if "load_forecast" in self.args:
entity_ids = self.get_arg("load_forecast", indirect=False)
if isinstance(entity_ids, str):
entity_ids = [entity_ids]

for entity_id in entity_ids:
if not entity_id:
self.log("Warn: Unable to fetch load forecast data, check your load_forecast setting in apps.yaml")
continue

attribute = None
if "$" in entity_id:
entity_id, attribute = entity_id.split("$")
try:
self.log("Loading extra load forecast from {}, attribute {}".format(entity_id, attribute))
data = self.get_state_wrapper(entity_id=entity_id, attribute=attribute)
except (ValueError, TypeError) as e:
self.log("Error: Unable to fetch load forecast data from sensor {}, exception {}".format(entity_id, e))
data = None

# Convert format from dict to array
if isinstance(data, dict):
data_array = []
for key, value in data.items():
data_array.append({"energy": value, "last_updated": key})
data = data_array

# Load data
load_forecast, _ = minute_data(
data,
self.forecast_days + 1,
self.midnight_utc,
"energy",
"last_updated",
backwards=False,
clean_increment=False,
smoothing=True,
divide_by=1.0,
scale=1.0,
required_unit="kWh",
)

if load_forecast:
self.log("Loaded the load forecast from {} load sensor; from midnight {}kWh to now {}kWh to midnight {}kwh".format(entity_id, load_forecast.get(0, 0), load_forecast.get(self.minutes_now, 0), load_forecast.get(24 * 60, 0)))
load_forecast_array.append(load_forecast)
else:
self.log("Warn: Unable to load the load forecast from {}. Skipping forecast source.".format(entity_id))
for entity_id, load_forecast, _, _ in self.fetch_cumulative_forecasts("load_forecast", "load forecast"):
self.log("Loaded the load forecast from {} load sensor; from midnight {}kWh to now {}kWh to midnight {}kwh".format(entity_id, load_forecast.get(0, 0), load_forecast.get(self.minutes_now, 0), load_forecast.get(24 * 60, 0)))
load_forecast_array.append(load_forecast)

# Add all the load forecasts together
for load in load_forecast_array:
Expand All @@ -2950,6 +3004,72 @@ def fetch_extra_load_forecast(self, now_utc, ml_forecast=None):
load_forecast_final[minute] = value
return load_forecast_final, load_forecast_array

def fetch_iboost_forecast(self):
"""
Fetch the hot water demand forecast used by the iBoost forecast planner.

Reads the sensors configured with iboost_forecast in apps.yaml (the same data format as
load_forecast: a cumulative kWh series, either a dict of ISO timestamp -> kWh or a list of
{last_updated, energy} entries), scales the series by iboost_forecast_scaling and converts
it into demand per plan interval (kWh, negative deltas clamped to zero) covering the
forecast horizon. Returns a dict of absolute minute (interval start) -> kWh, or an empty
dict when no forecast is configured or no usable future data could be fetched, in which
case the caller falls back to the legacy smart plan.
"""
if "iboost_forecast" not in self.args:
return {}

iboost_forecast_scaling = self.get_arg("iboost_forecast_scaling", 1.0)
sources = self.fetch_cumulative_forecasts("iboost_forecast", "iBoost demand forecast", scale=iboost_forecast_scaling)
if not sources:
self.log("Warn: iboost_forecast is configured but no forecast data could be loaded, using the legacy iBoost smart plan")
return {}

# Staleness is judged only from sources that actually loaded, so a recent but unusable
# sensor cannot vouch for a stale one
first_data_minute = min(first_minute for _, _, first_minute, _ in sources)
last_data_minute = max(last_minute for _, _, _, last_minute in sources)
if last_data_minute <= self.minutes_now:
self.log("Warn: iBoost demand forecast ends at minute {} which is not in the future, using the legacy iBoost smart plan".format(last_data_minute))
return {}
Comment thread
psweens marked this conversation as resolved.
if first_data_minute >= self.minutes_now + self.forecast_minutes:
self.log("Warn: iBoost demand forecast starts at minute {} which is beyond the planning horizon, using the legacy iBoost smart plan".format(first_data_minute))
return {}

# Convert the cumulative series into demand per plan interval, aligned to the same interval
# grid the planner books slots on. The grid covers the whole fetchable horizon (not just
# forecast_minutes) so a planning horizon raised after the fetch - the tariff compare sets
# forecast_minutes to 48h after fetching - still finds the demand. Per-minute positive
# deltas are taken per source (the same reading get_from_incrementing gives the load
# forecast) and only within that source's own raw extent, so a series that ends or resets
# never books phantom demand from minute_data's back-fill and one source's tail cannot
# mask another's draw. Minutes already elapsed in the current interval are skipped: any
# draw there is in the tank SoC reading.
demand = {}
total = 0.0
start_minute = int(self.minutes_now / self.plan_interval_minutes) * self.plan_interval_minutes
grid_end = max(start_minute + self.forecast_minutes, (self.forecast_days + 1) * 24 * 60)
for minute in range(start_minute, grid_end, self.plan_interval_minutes):
kwh = 0.0
for _, forecast, first_minute, last_minute in sources:
for offset in range(self.plan_interval_minutes):
offset_minute = minute + offset
if offset_minute < self.minutes_now:
continue
if (offset_minute < first_minute) or (offset_minute + 1 >= last_minute):
continue
kwh += self.get_from_incrementing(forecast, offset_minute, backwards=False)
demand[minute] = dp4(kwh)
total += kwh

if total <= 0:
self.log("Warn: iBoost demand forecast contains no future demand, using the legacy iBoost smart plan")
return {}

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

def fetch_carbon_intensity(self, entity_id):
"""
Fetch the carbon intensity from the sensor
Expand Down Expand Up @@ -3293,6 +3413,9 @@ def fetch_config_options(self):
self.iboost_charging = self.get_arg("iboost_charging")
self.iboost_gas_scale = self.get_arg("iboost_gas_scale")
self.iboost_max_energy = self.get_arg("iboost_max_energy")
self.iboost_tank_capacity = self.get_arg("iboost_tank_capacity")
self.iboost_tank_reserve = self.get_arg("iboost_tank_reserve")
self.iboost_fill_rate_threshold = self.get_arg("iboost_fill_rate_threshold")
self.iboost_max_power = self.get_arg("iboost_max_power") / MINUTE_WATT
self.iboost_min_power = self.get_arg("iboost_min_power") / MINUTE_WATT
self.iboost_min_soc = self.get_arg("iboost_min_soc")
Expand Down
Loading