iBoost: optional demand-forecast-driven smart planning (iboost_forecast) - #4756
Open
psweens wants to merge 7 commits into
Open
iBoost: optional demand-forecast-driven smart planning (iboost_forecast)#4756psweens wants to merge 7 commits into
psweens wants to merge 7 commits into
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_forecastis configured inapps.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_energyis 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: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_forecastis configured:fetch_iboost_forecast()(modelled onfetch_extra_load_forecast()) reads the configured sensors, scales the cumulative series byiboost_forecast_scalingand 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.clamp(iboost_tank_soc / 100 × capacity, 0, capacity)when the SoC sensor is configured and readable, else 0 so the whole forecast is provisioned.iboost_rate_threshold,iboost_rate_threshold_export, the gas thresholds, the element power, the per-calendar-dayiboost_max_energy − iboost_todaycap 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.iboost_smarton); when configured it takes over slot selection in those modes, including the time-ordered plan used wheniboost_smartis off.iboost_planstructure (start,end,kwh,average,cost), sorted by time with no duplicate starts, soin_iboost_slot(), the plan card,binary_sensor.predbat_iboost_activeand the export-window clash checks are unchanged.averageandcostuse 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 existingin_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_energyintraday, 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_forecastsets 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 theload_forecastattribute 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 asiboost_forecast, with the bug-fix case underiboost_smart), all on non-flat rate profiles: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_minutesto 48h after sensor data is fetched, so withforecast_hoursconfigured below 48 the comparison sees demand only over the shorter horizon; a follow-up could rebuild the forecast grid insiderecompute_iboost().Related
plan_iboost_smart()window-pricing/duplicate-slot fix is included as the first commit of this PR (separable).