Skip to content

iBoost: optional demand-forecast-driven smart planning (iboost_forecast) - #4756

Open
psweens wants to merge 7 commits into
springfall2008:mainfrom
psweens:claude/iboost-demand-forecast-i9lsss
Open

iBoost: optional demand-forecast-driven smart planning (iboost_forecast)#4756
psweens wants to merge 7 commits into
springfall2008:mainfrom
psweens:claude/iboost-demand-forecast-i9lsss

Conversation

@psweens

@psweens psweens commented Aug 26, 2026

Copy link
Copy Markdown

Summary

Predbat can optionally plan iBoost energy from a 30-minute hot-water demand forecast and the tank's current state of charge, treating the tank as a charge-only store, so heating is scheduled before each draw and stops once the forecast is covered. The feature is off unless iboost_forecast is configured in apps.yaml; existing behaviour is unchanged otherwise, and a regression test enforces that the legacy path produces identical output when no forecast is loaded.

Problem

iboost_max_energy is a per-calendar-day scalar; the smart planner books the cheapest slots without knowing when hot water is drawn or how much energy the tank already holds, so intraday shortfalls are served reactively at uncontrolled rates and full budgets are booked on days the cylinder is already hot (#1424). Users with SoC-reporting tanks rebuild the missing logic as template sensors plus an automation rewriting the budget intraday, which races the replan cycle around midnight.

Configuration

apps.yaml:

iboost_forecast:
  - sensor.hot_water_demand_forecast$results   # same format and parsing as load_forecast; cumulative kWh
iboost_forecast_scaling: 1.0                   # multiplier applied to the forecast, e.g. 0.11 for a series in % of an 11 kWh tank
iboost_tank_soc: sensor.tank_charge            # optional; percent of usable stored energy currently in the tank

Home Assistant entities (created under iboost_enable):

  • input_number.predbat_iboost_tank_capacity — usable stored energy, kWh (default 10)
  • input_number.predbat_iboost_tank_reserve — minimum stored energy to hold before each draw, kWh (default 0)
  • input_number.predbat_iboost_fill_rate_threshold — fill remaining headroom in any slot whose import rate is at or below this value, p/kWh (default −99, disabled). Implemented as a separate commit so it can be dropped in review without touching the rest.

A check on the configuration shape and naming would be appreciated.

Behaviour

When iboost_forecast is configured:

  • fetch_iboost_forecast() (modelled on fetch_extra_load_forecast()) reads the configured sensors, scales the cumulative series by iboost_forecast_scaling and converts it into demand per plan interval as the per-minute positive delta (the same reading the load forecast gets), logging a one-line summary. If the sensor is missing, unavailable, stale or yields no data, a warning is logged and the unchanged legacy planner runs for that cycle.
  • Initial stored energy is clamp(iboost_tank_soc / 100 × capacity, 0, capacity) when the SoC sensor is configured and readable, else 0 so the whole forecast is provisioned.
  • The tank level is maintained at interval resolution as a charge-only store: draws remove energy (clamped at zero), boosts add it (clamped at capacity). Two constraints hold for every interval: the level never falls below the reserve at any draw, and never exceeds the capacity after any boost.
  • Selection is earliest-deadline, cheapest-first: the intervals are walked in time order and whenever a draw would breach the reserve, the cheapest eligible earlier interval is booked (respecting iboost_rate_threshold, iboost_rate_threshold_export, the gas thresholds, the element power, the per-calendar-day iboost_max_energy − iboost_today cap and the capacity headroom), preferring intervals adjacent to already-booked ones on price ties so boosts consolidate into longer runs, and repeating until the draw is covered. If no eligible interval remains a warning names the uncovered interval and energy and planning continues.
  • The current interval is usually partially elapsed (the plan is rebuilt every few minutes): only its remainder can be booked and its slot is emitted starting at the current minute, mirroring the legacy planner's clamp, so booked energy is always deliverable. Demand already drawn earlier in the current interval is excluded during ingestion since the tank SoC reading reflects it.
  • With the fill threshold set, eligible intervals at or below it top the tank up to its capacity headroom, walking in time order so fill energy carries forward; after each fill the level trajectory is re-verified and later planned boosts the fill energy has made redundant are trimmed immediately, so the level stays at or below capacity everywhere and the day cap only ever counts energy that survives. (Within the set of qualifying slots, an earlier fill displaces a later booking regardless of relative price — flagging in case cheapest-first filling is preferred.)
  • The forecast planner runs in exactly the modes the smart planner runs in (iBoost enabled, and either both solar and battery modes off or iboost_smart on); when configured it takes over slot selection in those modes, including the time-ordered plan used when iboost_smart is off.
  • Slots are emitted in the existing iboost_plan structure (start, end, kwh, average, cost), sorted by time with no duplicate starts, so in_iboost_slot(), the plan card, binary_sensor.predbat_iboost_active and the export-window clash checks are unchanged. average and cost use each slot's own import rate. The level trajectory is logged at debug level and a one-line summary at info level.

The first commit is a separable two-line fix to plan_iboost_smart() this feature depends on: the sliding-window average priced every window from its first interval only, and the duplicate-slot guard tested a stale loop variable so overlapping windows could double-book a slot start. There is no separate PR for it; it is included here as its own commit with its own test.

Scope

Planner only. No change to run_prediction() or the C++ kernel, no parity revision, no binary rebuild; both prediction engines consume the plan through the existing in_iboost_slot() path. Carrying tank state into the prediction loop (so the solar and charging modes respect headroom) is a possible follow-up, deliberately excluded, as is publishing the modelled per-slot tank level (the iBoost plan slots are not currently published as an entity, so there is no attribute to attach it to).

Alternatives considered

External scaffolding: template sensors deriving a remaining-demand estimate, an automation rewriting iboost_max_energy intraday, or setpoint automations on the diverter. A scalar budget cannot express when the energy is needed, so heating still lands in the cheapest slots of the day regardless of draw timing, cannot span a multi-day horizon, and the intraday rewrite races the replan cycle around midnight.

Forecast source

load_forecast sets the precedent for ingesting an external forecast series (Predheat, PredAI). A producer exists: ML Forecast Lab (disclosure: the author is me), an open-source Home Assistant app that benchmarks state-of-the-art ML forecasting backends on the user's own sensor history and publishes calibrated forecasts as HA sensors in the load_forecast attribute format. For Mixergy tanks the demand series comes from the tank's own charge-drop meter, so it represents actual draw rather than heating energy.

Testing

New cases in apps/predbat/tests/test_iboost.py (registered as iboost_forecast, with the bug-fix case under iboost_smart), all on non-flat rate profiles:

  • window pricing and duplicate-slot guard fix (fails on the pre-fix planner)
  • forecast ingestion: parsing, scaling, negative-delta clamping, multi-sensor summing, and fallbacks for unconfigured/missing/stale sensors, scalar states and data beyond the planning horizon
  • energy booked before a draw, not in a cheaper slot after it
  • a draw before any cheap slot books the cheapest preceding slot
  • a full tank books nothing (Option to end iBoost early and replan, e.g. when hot water cylinder recovered #1424 behaviour)
  • reserve held at every draw; capacity never exceeded by a large cheap window; capacity headroom binding across a draw–refill–draw cycle
  • calendar-day cap respected alongside the forecast
  • import, export and gas threshold eligibility; adjacency consolidation on price ties
  • mid-interval replans: the partially elapsed current interval is pro-rated in both the planner and the ingestion
  • uncovered demand warns and later draws are still planned; a zero element power books nothing
  • fill threshold fills only qualifying slots, the trajectory stays at or below capacity after trimming, and trimmed energy is refunded to the day cap
  • regression: with no forecast loaded the legacy planner output is identical before and after a forecast plan has run, and every plan is sorted with positive-length, non-overlapping, duplicate-free slots

Commands run: ./run_pre_commit (all hooks pass) and ./run_all --quick (all tests pass), plus ./run_all --test iboost_smart --test iboost_forecast.

Known limitation: the tariff compare feature raises forecast_minutes to 48h after sensor data is fetched, so with forecast_hours configured below 48 the comparison sees demand only over the shorter horizon; a follow-up could rebuild the forecast grid inside recompute_iboost().

Related

claude added 7 commits August 25, 2026 18:50
plan_iboost_smart() priced each sliding window from its first interval only
(the rate lookup used the window start rather than the interval being
summed) and the duplicate-slot guard tested a stale loop variable, so
overlapping windows could book the same slot start twice. Price windows
from every interval they cover and key the guard on the slot start.

Adds a non-flat-rate test case with 60-minute windows that fails on both
defects, plus plan invariant checks (sorted, no duplicate starts) applied
to every iBoost smart test.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QiqN8TJpbXRsSkph6yNSx
Add the configuration surface for demand-forecast-driven iBoost planning:
iboost_forecast (sensor list, same cumulative-kWh format as load_forecast),
iboost_forecast_scaling and iboost_tank_soc in apps.yaml, plus
input_number.predbat_iboost_tank_capacity and
input_number.predbat_iboost_tank_reserve Home Assistant entities.

fetch_iboost_forecast() reads the configured sensors, scales the series and
converts it into demand per plan interval (per-minute positive deltas, the
same reading the load forecast gets), judging staleness from the raw
timestamps since minute_data back-fills beyond the last data point. When
the forecast is missing, stale or empty it returns an empty dict and the
legacy smart plan is used unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QiqN8TJpbXRsSkph6yNSx
When a hot water demand forecast is loaded, plan_iboost_smart() delegates
to plan_iboost_forecast(): the tank is modelled as a charge-only store
(iboost_tank_capacity kWh, initial level from iboost_tank_soc or empty)
and the planner walks the plan intervals in time order, booking the
cheapest eligible earlier interval whenever a draw would take the level
below iboost_tank_reserve. Bookings respect the element power, the
per-day iboost_max_energy cap, the rate and gas thresholds and the tank
capacity; uncovered demand logs a warning and planning continues. Slots
keep the legacy structure (start, end, kwh, average, cost), priced at
each slot's own import rate, with a regression test proving the legacy
path is unchanged when no forecast is configured.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QiqN8TJpbXRsSkph6yNSx
Document iboost_forecast, iboost_forecast_scaling and iboost_tank_soc in
apps-yaml.md alongside the other iBoost items, cross-referencing the
load_forecast data format, and add an 'iBoost demand forecast' section to
customisation.md covering the charge-only store model, the tank capacity
and reserve entities, the per-day iboost_max_energy cap interaction and
the fallback to the legacy smart plan.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QiqN8TJpbXRsSkph6yNSx
Add input_number.predbat_iboost_fill_rate_threshold: any eligible slot
with an import rate at or below the threshold tops the tank up to its
capacity headroom regardless of the forecast, for example to heat fully
on free or negative rates. After filling, the level trajectory is
re-verified and later planned boosts the fill energy has made redundant
are trimmed so the level stays at or below the capacity everywhere. The
default of -99 p/kWh disables filling, leaving the planner unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QiqN8TJpbXRsSkph6yNSx
Address findings from an adversarial review of the forecast planner:

- The current plan interval is usually partially elapsed (replans run
  every few minutes): cap its bookable energy to the remaining minutes
  and emit its slot starting at minutes_now, mirroring the legacy
  planner's clamp, so booked energy is always deliverable and the day
  cap is never consumed by phantom bookings. Demand already drawn
  earlier in the current interval is likewise excluded during ingestion
  since the tank SoC reading already reflects it.
- The fill pass now trims displaced boosts immediately after each fill
  and derives day-cap usage from the surviving plan, so a fill no
  longer blocks later eligible fill slots with day-cap usage the trim
  then refunds.
- Non-list forecast data (a scalar state from a sensor configured
  without an attribute suffix) is skipped with a warning instead of
  raising and aborting the fetch cycle, and data lying wholly beyond
  the planning horizon falls back to the legacy plan.
- The candidate scan uses a running suffix maximum so the capacity
  headroom check is O(1) per candidate.
- Docs: state the modes in which the forecast planner runs and fix the
  entity-count wording.

Test hardening: unaligned minutes_now cases for the planner and the
fetch, binding import/export/gas threshold cases, a capacity-headroom
binding case, an adjacency tie-break case, fill day-cap accounting,
multi-sensor summing, scalar-state and beyond-horizon fallbacks, a
zero-power guard, slot length/overlap invariants, pinned thresholds in
the helpers and shared-state restoration after the suite.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QiqN8TJpbXRsSkph6yNSx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants