How it works¶
This page describes the moving parts and their timing. For the mathematics of the optimizer itself — the Bellman equation, the cost function, the terminal condition — see the algorithm page.
Architecture¶
The integration runs three cascading coordinators, each feeding the next:
| Coordinator | Interval | Reads | Produces |
|---|---|---|---|
| Weather | 30 min | open-meteo.com | Solar radiation (GHI) + wind speed forecast |
| Forecast | 15 min | Weather, price sensor, energy meters | PV production + consumption forecast |
| Optimization | 15 min | Forecast, SoC sensors | DP schedule, shadow price, setpoint |
flowchart TD
API["open-meteo.com"] -->|HTTP| WC
WC["<b>Weather Coordinator</b><br/>every 30 min"] --> FC
PS["Price sensor<br/>(forecast attributes)"] --> FC
EM["Energy meters<br/>(recorder history)"] --> FC
FC["<b>Forecast Coordinator</b><br/>every 15 min<br/><i>PV + consumption</i>"] --> OC
SOC["Battery SoC sensors"] --> OC
OC["<b>Optimization Coordinator</b><br/>every 15 min<br/><i>runs the DP</i>"] --> SCHED["Schedule<br/>Shadow price<br/>Optimal power"]
OC --> ZG
GRID["Grid power meter"] --> ZG
ZG["<b>Zero-grid controller</b><br/>every ~5 s"] --> SETP["Battery Setpoint (W)"]
style WC fill:#0f766e22,stroke:#0f766e
style FC fill:#0f766e22,stroke:#0f766e
style OC fill:#0f766e22,stroke:#0f766e
style ZG fill:#b4530922,stroke:#b45309
The zero-grid controller runs separately at roughly 5-second resolution — it reacts to
the live grid meter, not to the forecast. That is the loop responsible for the
zero_grid mode and for the real-time part of the hybrid modes.
The optimizer does not only run on the 15-minute clock
It also re-runs immediately when:
- a new price period starts
- there is a significant price change
- a stale price or SoC sensor becomes available again
- the midpoint of the current price period is reached (a scheduled "mid-period correction" run)
Every run re-solves the entire rolling horizon from scratch, so two schedule snapshots taken a few minutes apart can look meaningfully different. See the learning period below.
Subentry structure¶
Battery Controller uses subentries to manage hardware flexibly:
- Battery subentries — each with its own capacity, power limits, SoC sensor, and optional power sensor.
- PV array subentries — each with its own peak power, orientation, tilt, and coupling type.
The optimizer aggregates all battery subentries into a single virtual battery for planning. When executing the schedule, the required power is split across the physical batteries proportional to their available headroom (charging) or stored energy (discharging).
Historical price model (pre-day-ahead fallback)¶
Day-ahead electricity prices (Nordpool, ENTSO-E) are published around 13:00 CET. Before that, the integration uses a self-learning historical price model so the optimizer can still run on a reasonable forecast.
flowchart TD
START["Optimizer run starts<br/>needs 24-36 h of prices"] --> Q1{"Does the price sensor<br/>cover the full horizon?"}
Q1 -->|"Yes — day-ahead published"| USE["Use live day-ahead prices<br/>for the whole horizon"]
Q1 -->|"No — before ~13:00 CET,<br/>or a short forecast"| FILL["Live prices for the hours<br/>they cover"]
FILL --> EXT["Historical model fills<br/>the remaining hours"]
USE --> DP["Run the DP"]
EXT --> DP
style USE fill:#0f766e22,stroke:#0f766e
style EXT fill:#b4530922,stroke:#b45309
The same model extends the planning horizon when live prices cover less than 24 hours. The modelled hours are anchored to the first hour the live prices do not cover, so the predicted price curve stays on the clock — the cheap and expensive windows it fills in sit at the hours the model actually predicts for them.
It builds lookup tables from data in the Home Assistant recorder, keyed on:
- hour of day
- weekday
- solar irradiance (GHI)
- wind speed
The last two matter because both push wholesale prices down when supply is high — which is why enabling the Solar Irradiance and Wind Speed diagnostic sensors early gives the model more to learn from.
Learning period: give the optimizer time to calibrate¶
Allow at least 2–4 weeks of operation before judging performance
The optimizer uses rolling-horizon dynamic programming: on every run it re-solves the entire planning horizon (24–36 hours) from scratch, starting from the current SoC. The value of stored energy at the end of the horizon — the terminal condition — is set from the price forecast itself (a clipped tail-average of the feed-in price), not carried over from the previous run.
Because battery capacity is limited, this is a global allocation problem: the DP weighs using the current cheap or negative-price window now against reserving capacity for a better opportunity later in the horizon. On a rerun, small input changes can shift that trade-off enough to visibly change today's plan:
- A price forecast update — day-ahead prices publishing, a revised historical-model estimate, or simply a new period starting.
- An updated PV or consumption forecast.
- The actual SoC drifting from what the previous plan assumed — for example because Hybrid mode diverged to zero-grid instead of following the DP schedule exactly.
Because the whole horizon is re-optimized rather than patched incrementally, even a modest change in one input can shift how much capacity is allocated to the current window versus a later one. Two runs a few minutes apart can show different schedules for what looks like the same situation. This is expected DP behaviour, not a bug.
Why it is most visible in the first weeks¶
Two data-driven models are still building up from recorder history:
- The household consumption pattern and historical price model need time to learn typical usage and price patterns.
- The shadow price (λ) — the marginal value of storage, used by Hybrid mode as its charge/discharge threshold — is noisier while the forecasts feeding it are inaccurate.
During this convergence period you may notice:
- The schedule changing more noticeably between runs, including between two runs within the same 15-minute slot.
- The optimizer occasionally charging or discharging more aggressively than expected.
- Estimated savings that appear lower than the long-run optimum.
The longer the integration runs, the more accurate the underlying forecasts become and the more stable the resulting schedule.
Checking what has actually been learned¶
The corrections are learned from your own installation, and each one is published as a diagnostic sensor so you can see where it stands without downloading anything:
| Sensor | Where | What it measures |
|---|---|---|
| Charge Efficiency Correction | main device | How much SoC the fleet really gains within a step, against what the model assumed |
| Discharge Efficiency Correction | main device | The same for discharging |
| Charge / Discharge Efficiency Correction <battery> | each battery | The same, measured on that pack alone |
| PV Forecast Correction <array> | each PV array | What each array really produces, against its forecast |
The two sensors on the main device are the fleet figures — the capacity-weighted
average of the individual batteries, and what the optimizer actually plans with, since it
plans one state of charge for all of them together. With more than one battery that
average describes neither pack when they differ: the controller runs one battery at a
time rather than splitting every setpoint, so the per-battery sensors are where a single
ageing pack becomes visible. A battery that was not asked to do anything reports
battery_not_dispatched — that is not a fault, it means the other battery has been
doing the work.
They all read 100% when reality matches the model. That is also the value they start at, so the number alone cannot tell you whether anything has been measured — the attributes say which:
samples— how many observations are behind the value. At0, nothing has been learned yet.applied— whether the correction is actually changing the plan. A PV correction needs a full sample window first; a battery correction within measurement noise of nominal is stored but never applied.last_result— what the most recent attempt did, and if it took no sample, why not.
Some of those reasons are permanent for a given setup rather than a passing condition, and they are the usual explanation for a sample count that never moves:
last_result |
What it means |
|---|---|
dc_coupled_pv |
Battery calibration is skipped entirely on DC-coupled systems: passive PV charging during a step would be indistinguishable from the battery charging faster than modelled. |
plan_not_executed |
The battery is not following the DP schedule verbatim, so the SoC change does not measure efficiency. Normal in Zero Grid and Manual mode, and common in Hybrid. |
direction_not_planned |
The optimizer has not planned this direction recently. Simply waiting is the fix. |
no_measured_production_sensor |
The PV array has no production counter configured, so there is nothing to compare its forecast against. |
production_outside_usable_band |
The array is producing below 10% or above 90% of its rating — too little signal, or close enough to clipping that the shortfall is not a forecast error. |
battery_not_dispatched |
This battery was not given a setpoint in this direction, so it has nothing to report. The controller concentrates on one pack at a time, so this alternates between batteries. |
To reset a correction that has gone wrong, call the
battery_controller.reset_charge_efficiency_calibration,
reset_discharge_efficiency_calibration or reset_pv_calibration service.
Known limitations¶
- Optimization horizon — the DP optimizer uses a rolling 24–36 hour horizon. Decisions near the end of the horizon depend on the shadow price rather than explicit future prices. The shadow price converges over several days of operation.
- Price forecast dependency — the optimizer requires a price sensor with forecast attributes. Without future prices only the historical model is used, which reduces arbitrage accuracy.
- Day-ahead gap — before day-ahead prices publish (around 13:00 CET), the schedule rests on the historical model and may be less optimal.
- Single aggregate battery — multiple batteries are aggregated into one virtual battery for optimization, then split proportionally. Batteries with very different chemistries or SoC ranges may not be split optimally.
- LFP assumptions — the efficiency model assumes flat efficiency across the SoC range, as is typical for LFP cells. Chemistries with significant SoC-dependent efficiency variation are not modelled.
- No direct hardware communication — Battery Controller writes setpoints to Home Assistant entities only. Connecting these to your inverter is your automation's job.
- Consumption pattern learning — the consumption model needs several weeks of kWh sensor history for accurate patterns. Forecasts are less accurate during the initial period.
- Sub-step resolution — the cost function prices the net grid exchange over a whole time step. Variation inside a step averages out, so when the meter flows both ways within one step, the difference between the buying and selling price on the smaller of the two directions is not seen. This needs the planned flow to sit near zero, which makes it mainly a Follow Schedule concern — Hybrid hands those periods to zero-grid.