This guide explains how the HSEM (Home Assistant Solar Energy Management) planner works. It covers inputs, outputs, the cost function, safety modes, and worked examples for five common scenarios a real installation will encounter.
See also:
docs/planner-spec.md— the normative specification that governs all planner invariants and implementation rules.
- Overview
- Planning inputs
- Planning outputs
- EV planned load integration
- Cost function
- Candidate generation and selection
- Safety modes
- Data quality diagnostics
- Scenario examples
- Reading the plan explanation
- Known limitations
The HSEM planner is a forward-looking, cost-minimising battery scheduler. Every time the coordinator runs (typically every minute) the planner:
- Reads the current battery state, electricity prices, and PV forecast.
- Generates a time grid of slots covering the planning horizon (12, 24, 36, or 48 hours).
- Populates each slot with expected house load, PV production, and prices.
- Evaluates several candidate strategies (charge from grid, discharge only, solar only, etc.).
- Scores every candidate with the cost function.
- Writes the lowest-cost valid plan to the
HourlyRecommendationobjects consumed by the coordinator.
The planner is pure Python with no Home Assistant imports. It runs synchronously, produces deterministic output for identical input, and is fully testable with plain pytest.
All inputs are collected in the PlannerInput dataclass
(custom_components/hsem/models/planner_inputs.py).
| Field | Type | Description |
|---|---|---|
now_iso |
str |
ISO-8601 timezone-aware timestamp of the planning moment (e.g. "2024-06-15T14:00:00+02:00") |
time_zone |
str | None |
IANA name of the HA time zone (e.g. "Europe/Copenhagen"); the planner re-expresses now in it so DST days get their real 23 or 25 hours. None plans in the fixed offset of now_iso |
interval_minutes |
int |
Slot width in minutes — 15 or 60 |
interval_length_hours |
int |
Planning horizon length — 12, 24, 36, or 48 hours |
The total number of slots generated is (interval_length_hours * 60) // interval_minutes on an ordinary day; a DST day has 4 × (60 / interval_minutes) fewer or more (see planner-spec.md → Slot grid and DST).
| Horizon | 15-min slots | 60-min slots |
|---|---|---|
| 12 h | 48 | 12 |
| 24 h | 96 | 24 |
| 36 h | 144 | 36 |
| 48 h | 192 | 48 |
| Field | Type | Description |
|---|---|---|
battery_soc_pct |
float |
Current SoC percentage (0–100) |
battery_rated_capacity_kwh |
float |
Nameplate capacity in kWh |
battery_end_of_discharge_soc_pct |
float |
Minimum allowed SoC floor (%) |
battery_max_soc_pct |
float |
Maximum allowed SoC ceiling (%, default 100) |
battery_max_charge_power_w |
float |
Maximum charge power in Watts |
battery_max_discharge_power_w |
float | None |
Maximum discharge power in Watts (None = unlimited). The rated-capacity value from get_max_discharge_power() (2500 W with a warning for unlisted capacities), bounded by the discharging-power number entity's max attribute (issue #1291) |
The planner converts power limits to per-slot energy limits internally:
max_charge_per_slot_kwh = battery_max_charge_power_w / 1000 * (interval_minutes / 60)
| Field | Type | Description |
|---|---|---|
battery_purchase_price |
float |
Purchase price of the battery (local currency) |
battery_expected_cycles |
int |
Expected total lifetime cycles |
battery_cycle_cost_per_kwh |
float |
Explicit depreciation cost per kWh cycled |
battery_capacity_loss_pct |
float |
Expected capacity loss at end-of-life (%), default 30 |
battery_charge_efficiency_pct |
float |
Charge-side efficiency (%), e.g. 98 |
battery_discharge_efficiency_pct |
float |
Discharge-side efficiency (%), e.g. 98 |
When battery_cycle_cost_per_kwh is 0.0, the planner auto-derives cycle cost from
purchase price, rated capacity, expected cycles, and capacity loss at EOL:
depreciation = (purchase_price × capacity_loss_pct / 100)
/ (2 × usable_capacity_kwh × expected_cycles)
threshold = depreciation
Conversion efficiency is physical in the site balance: storing x kWh draws
x / charge_efficiency AC and removing x kWh delivers
x * discharge_efficiency AC. Grid import cost and export revenue price the
loss exactly once; the compatibility conversion_loss_cost field is zero. The 2× factor
in the depreciation term accounts for one full cycle (charge + discharge). The
capacity_loss_pct (default 30 %) accounts for the fraction of the battery's
value that is consumed over its lifetime — typically 20 % physical capacity
loss at 6000 LiFePO4 cycles plus ~10 % margin for calendar ageing.
The excess_export_price_threshold and the schedule profitability guard both use this
depreciation threshold. Users who want extra margin can set battery_cycle_cost_per_kwh
to a positive value, which is added on top.
The planner computes a dynamic discharge floor — a bridge-to-refill minimum SoC that prevents the battery from being discharged below the level needed to reach the next solar refill window:
reserve_kwh = house load until the next refill × safety_margin
effective_floor = configured_min_soc_pct + reserve_kwh / rated_capacity_kwh × 100
(never above the configured maximum SoC)
The reserve is held on top of the configured minimum SoC: a 2 kWh reserve on a 10 kWh battery with a 5 % minimum is a 25 % floor (issue #1221).
where safety_margin is a self-correcting multiplier that starts at
1.15 and stays within 1.05–1.50. It learns once per day: two days in a row
where the SoC fell below the floor raise it by 0.05, and seven days in a row
where the SoC stayed well above the floor lower it by 0.02. A floor the
battery never reached does not count (issue #1141). The margin resets to
1.15 when Home Assistant restarts. The floor is clamped to the hardware
minimum SoC.
This prevents the planner from discharging the battery late in the evening when the next day's solar forecast is insufficient to refill it — the battery retains enough energy to cover the gap. Without this guard, the planner would discharge to the configured floor every night, forcing morning grid imports when solar is scarce.
To see planned grid charges, each replan first solves once without the floor and reads that reference plan's charges (issue #1140); then it solves again with the resulting floor. If the reference plan charges from the grid before the next solar surplus, that charge is the refill: the floor reserves the house load up to the charge and nothing behind it. How much the plan buys never moves the floor (issues #1214, #1220); a charge in the current slot leaves nothing to reserve, and the floor is the configured minimum. The floor never reads the previous plan, and it does not depend on which of several equally priced night slots the reference plan happens to charge in (issue #1198): a planned charge counts from the first slot of its price. With excess export enabled, a plan that sells the battery in the evening has to buy the configured discharge buffer back before the next solar surplus; that purchase is not a refill (issue #1239), so the night behind it is still reserved. The floor can still step up once during a night, when the planned grid refill has happened and the reserve for the rest of the night to the solar surplus takes over. A cheap window before the next solar surplus is a refill whether or not the plan charges in it (issue #1247): a slot priced within one battery cycle cost of the cheapest price between it and the surplus, with dearer slots behind it, ends the bridge, because the plan can buy there for those slots. So a 0.15 night before a 0.12 day reserves the evening before it and nothing behind it, and inside the window the floor is the configured minimum at any SoC. Only a night at the cheapest price of the whole look-ahead releases the evening reserve as well (issue #1156).
The reference plan also supplies the house load and PV the floor bridges over, so the floor and the plan work from the same forecast. Before issue #1187 the floor compared the load of one slot with a whole hour of PV, which at 15- or 30-minute slots made it see a solar refill too early and reserve too little. Floors on such setups are higher since that fix on days when PV stays below the house load. Hourly setups keep the same arithmetic; their floor can still move a little, because the bridge now uses the planner's corrected PV.
A cheap night releases the floor even when the reference plan does not buy
there (issue #1156). This happens when tomorrow's PV will refill the battery
anyway. A slot counts as an affordable refill when its import price is
within one battery cycle cost of the cheapest import price in the next 48 h,
and the battery could charge enough there to cover the load bridged so far.
A night that is not the cheapest time of the look-ahead does not count, for
example a 0.15 night before a 0.12 day. Neither do flat prices. The sensor then
reports refill_type: grid_available.
The reserve declines through the night (issue #1188). The floor above is the reserve needed now. One slot later the bridge is one slot shorter, and after the refill no reserve is needed at all. The planner therefore gets the floor for every slot, not one number:
reserve at the start of slot t = house load from slot t to the refill slot × safety_margin
reserve from the refill slot on = 0
In the plan each slot must end with at least the next slot's reserve. So:
- A battery above the reserve serves the house through the night and ends each slot at or above the declining reserve. Only what goes beyond that is blocked: exporting the battery or charging an EV from it.
- A battery below the reserve cannot cover every hour until the refill. It serves the most expensive of those hours and waits in the cheapest ones, where the house imports (issue #1222). A full battery under a long bridge is therefore used at the evening peak and not held. When all those hours cost the same, the battery waits first and serves the last hours. It is not charged from the grid to reach the reserve, the plan reports its real SoC, and it can charge into its full headroom (issue #1094).
- From the refill slot on the plan may use the battery down to the hardware minimum, so the day after the refill is planned with the real battery.
Before this change the planner held the floor constant for all 48 hours. The
timeline then showed batteries_wait_mode with grid import all night, while
in reality each replan lowered the floor and the battery kept feeding the
house (issue #1125).
sensor.hsem_effective_discharge_floor_sensor still shows the reserve at the
start of the current slot. During the slot the battery may go down to the next
slot's reserve, so the live SoC can read slightly below the sensor until the
next replan.
The planner's battery model always starts at the hardware minimum SoC:
planned capacity (estimated_battery_capacity_kwh) is kWh above the hardware
minimum, with or without the dynamic floor.
HSEM predicts house load for each slot. Two modes are available (toggled via
hsem_ml_consumption_enabled):
- Legacy (default): Weighted average across four rolling windows (1d, 3d, 7d, 14d) with IQR outlier detection. Requires HSEM custom sensor entities.
- ML (optional): Ridge regression on recorder history with day-of-week, day-of-year seasonality, and optional outdoor temperature (plus an optional wind-chill index derived from the same weather forecast entity). No custom sensors needed.
Regardless of mode, the planner receives a per-hour HourlyConsumptionAverage:
| Field | Type | Description |
|---|---|---|
consumption_averages |
list[HourlyConsumptionAverage] |
Per-hour (0–23) historical averages |
weight_1d |
int |
Weight for the 1-day average (integer %) |
weight_3d |
int |
Weight for the 3-day average (integer %) |
weight_7d |
int |
Weight for the 7-day average (integer %) |
weight_14d |
int |
Weight for the 14-day average (integer %) |
Weights must sum to 100. Default split: 1d=25 %, 3d=30 %, 7d=30 %, 14d=15 %.
Each HourlyConsumptionAverage carries:
hour— 0-based clock-hour (0–23)avg_1d,avg_3d,avg_7d,avg_14d— average kWh for that hour over each window
The planner applies a median-ratio outlier detection algorithm that flags anomalous windows and redistributes their weight to stable windows before combining the averages.
In addition, the optional weekday/weekend profiling feature (WeekdayProfile,
#612) maintains separate 24-slot EWMA profiles for Mon–Fri and Sat–Sun. When
active, the appropriate profile is merged into the consumption prediction to
better capture the distinct usage patterns between workdays and weekends.
| Field | Type | Description |
|---|---|---|
price_points |
list[PricePoint] |
Hourly import/export prices |
Each PricePoint carries:
hour— 0-based clock-hourimport_price— cost to buy 1 kWh from the grid (local currency/kWh)export_price— revenue from selling 1 kWh to the grid (local currency/kWh)
Prices sourced from Energi Data Service (EDS) are auto-detected per attribute from their timestamps. The populator stores the raw currency/kWh value and the planner receives that raw rate unchanged, whether the source cadence is 15 min, 30 min, or 60 min. See Price interval semantics in the spec.
| Field | Type | Description |
|---|---|---|
solcast_slots |
list[SolcastSlot] |
Forecast PV production, per hour or per planner slot |
Each SolcastSlot carries:
hour— 0-based clock-hourpv_estimate— average PV power (kW) over the entry's period. For an hourly entry that is also the hour's energy in kWhday_offset— whole days from the planning midnightslot_in_day— optional index of the planner slot within its day.Nonemeans the entry covers the whole hour and is split evenly over its slots
The forecast reaches the plan at the source's resolution (issue #1191):
| Solcast attribute | Cadence | What the planner uses |
|---|---|---|
detailedHourly |
60 min | One value per hour, split evenly over the hour's slots |
detailedForecast |
30 min | One value per half-hour at 15- and 30-minute slots |
| any source at 15 min | 15 min | One value per quarter-hour at 15-minute slots |
A source finer than the planner slot is averaged over the slot, so the hour's
energy is the same at every slot interval. With both Solcast attributes
enabled the half-hourly one is used. A slot with no PV data is planned with
zero PV and listed in the *_pv_missing_hours data-quality fields.
For multi-day horizons, a confidence decay factor is applied to PV estimates for future days to account for forecast uncertainty:
| Day offset | Decay | Meaning |
|---|---|---|
| 0 (today) | 1.00 | No decay |
| 1 (tomorrow) | 0.90 | 10 % conservative discount |
| 2 (day after) | 0.80 | 20 % conservative discount |
Prices are not decayed because spot-market prices are typically firm by mid-day.
Raw Solcast PV estimates are corrected in two stages before entering the planner (issue #602):
Per-hour accuracy factors: The SolarForecastCorrector maintains a
4-day rolling history of (forecast, actual) ratios for each hour of the
day. The ratio for a given hour is clamped to [0.3, 1.5] so that a
single anomalous day cannot permanently distort the correction. The
factor is updated once per day when a full 24 h of actual production is
available.
Intra-hour residual correction: When live solar production data is available mid-hour, the last 4 slots (2 hours) of the forecast horizon are adjusted with an exponentially decaying residual:
corrected_pv = raw_pv × hour_factor × residual_factor
residual_factor = 1.0 + (live_pv − forecast_pv) / max(forecast_pv, 0.01)
× decay_per_slot
where decay_per_slot is [0.66, 0.44, 0.22, 0.05] across the 4 slots
(linear decay over 2 h). The correction is conservative in both
directions: it raises PV estimates when live production exceeds forecast
(cloud clearing) and lowers them when live production falls short (cloud
arrival).
The raw Solcast data is never mutated; corrections are only applied at consumption time when the planner reads PV estimates.
Live house and PV readings that replace the current slot's forecast (see
Live data injection in the
planner spec) are only authoritative when the coordinator can prove the
reading is genuine. coordinator_builder._resolve_live_house_measurement /
_resolve_live_solar_measurement mark a reading available only when its
configured entity is set, the value is present, finite, and non-negative, and
the entity is not on the live-state's missing-entities list. A measured 0 W
is a valid, available reading — it is no longer indistinguishable from "no
reading yet". When a channel is unavailable, the engine leaves that slot's
forecast value untouched instead of injecting a stale or invalid number.
A short-window median sampler (utils/live_power.py::LivePowerWindow) smooths
bursty live power over multiple ticks before it reaches the planner. The
coordinator wires it in via coordinator_live_power.py (issue #797): a
dedicated fast timer keeps the window fresh between full planning cycles,
and each cycle also seeds the window from its own immutable snapshot so a
slow-cadence cycle without an intervening fast-timer sample still sees an
up-to-date estimate. When the house meter includes EV charger load, a
configured charger-status entity that is unknown or unavailable makes the
house sample ambiguous and therefore unavailable; HSEM never treats that
missing boolean state as a definite idle charger (issue #1056).
| Field | Default | Description |
|---|---|---|
excess_export_enabled |
False |
Enable forced battery → grid export during high-price slots |
excess_export_discharge_buffer_pct |
10.0 |
Conditional safety reserve retained through the demand window after battery export; one contiguous PV-surplus run shares one checkpoint |
excess_export_price_threshold |
Auto-calculated | Computed at runtime from battery depreciation settings (purchase price, expected cycles, usable capacity) via calculate_recommended_threshold(). |
export_min_price |
0.0 |
Minimum export price for intentional battery-to-grid discharge. The inverter no longer throttles the grid feed-in limit for positive prices; surplus PV export is always allowed (issue #767). Negative export prices still trigger a physical block because exporting then costs money. |
battery_export_min_price |
0.0 |
Per-slot hard floor for intentional battery-to-grid export (issue #752). When > 0 and a slot's raw export_price is strictly below this value, the MILP caps ed[t] so the battery can only serve house load (no grid export) for that slot — force_batteries_discharge is never labelled there. Reaching the threshold does NOT auto-trigger export; the optimizer still decides. Applies only to intentional battery-to-grid export, not to normal battery self-consumption, PV export, or PV charging. Set to 0 to disable. |
export_fee_per_kwh |
0.0 |
Retailer margin/balancing fee per kWh exported (issue #925), netted out of the export price everywhere export profitability is decided (applier physical block, MILP objective, cost function) — but never applied to export_min_price/battery_export_min_price floor comparisons, which stay on the raw price. A raw price that is positive but net-negative after this fee is treated like a negative price, so PV export gets physically curtailed instead of exported at a loss. |
The reserve uses one checkpoint for every slot in a contiguous forecast PV-surplus run. That checkpoint follows the run's demand window—immediately before the next distinct surplus run, or horizon end. Planned PV or grid charge may restore the reserve before it is measured. If it cannot, battery-origin export may be suppressed while direct PV export remains available.
| Field | Default | Description |
|---|---|---|
battery_target_soc_enabled |
False |
Opt-in. Off keeps every plan exactly as it is today. |
battery_target_soc_pct |
100.0 |
Target as absolute SoC (includes the hardware end-of-discharge limit). |
battery_target_soc_time |
"17:00:00" |
Daily local time by which the target should be reached. Next occurrence only. |
The target is a preference for building an extra reserve, for example for the evening, to cover days when the forecast is wrong. It is not a force-charge function:
- Only PV that the normal plan would export is used. HSEM never buys extra grid energy for the target.
- Battery energy the normal plan sells before the target time is still sold (issue #1203). The target never cancels a sale from the battery, so on an evening without PV it changes nothing.
- The grid charging and the discharge the normal plan already needs stay exactly as they are, including an earlier discharge window in the morning.
- It is a deadline, not "charge as soon as possible". If the forecast shows enough surplus later, HSEM may still export now when that pays better. If not, it stores the current surplus. With no surplus, the battery stays where the normal plan leaves it.
- Surplus above the target is exported, and after the target time the battery behaves normally.
HSEM first computes the normal plan. Only if that plan misses the target does
it plan again with two extra rules. Grid import is fixed at the normal plan's
value in every slot up to the target time, and may not rise afterwards. And
what the normal plan sells from the battery up to the target time is still
exported. The only way left to raise the battery level at the target time is
then to export less PV, and the planner gives up the lowest-paid export
first. If that second plan cannot raise the level at all, the normal plan is
kept as it is (stage2_status: no_gain).
A charge-past-target EV wants the same spare PV. The house battery goes first; the EV gets what is left once the battery has what it needs.
The working-mode sensor's battery_target attribute shows the result for the
next occurrence: the target, the level the normal plan would reach
(stage1_projected_kwh), the level the plan reaches (projected_kwh), and
any shortfall_kwh.
It also shows what the target costs (issue #1185). preference_cost is the
plan's money cost with the target minus its cost without it
(stage2_cost − stage1_cost), over the planning horizon, and
preference_cost_per_kwh divides it by the extra energy held at the target
time. Two things to keep in mind when reading it:
- It is a per-plan figure. Every replan recomputes it for the hours ahead, so adding up the values of one day counts the same hours many times.
- The battery usually ends the horizon with more energy because of the
target. That energy is worth something later, which the money figure does
not include.
terminal_soc_value_deltashows the planner's estimate of it (negative means the target leaves more in the battery).
These fields are empty when the normal plan already meets the target, and when the target could not add anything to it. See planner-spec.md for the model.
| Field | Default | Description |
|---|---|---|
months_winter |
[1,2,3,10,11,12] |
Months classified as winter. All 12 months may be winter (TOU year-round, issue #725); the summer set is then empty. |
house_power_includes_ev |
True |
Whether the house consumption sensor already includes EV charger power |
| Field | Default | Description |
|---|---|---|
main_fuse_amps |
0 (disabled) |
Main fuse/breaker rating in amps (e.g., 25, 35). When set, the MILP optimizer respects this limit as a soft constraint on total grid import power per slot. Set to 0 to disable. |
The MILP uses a soft (penalty-based) constraint so the solver never becomes infeasible — if house base load alone exceeds the fuse rating, the plan is still returned with the violation flagged. The formula for converting amps to kWh/slot is:
max_grid_import_per_slot_kwh = main_fuse_amps × 230 V × 3 phases / 1000 × (interval_minutes / 60)
This assumes balanced three-phase load at 230 V phase-to-neutral. When the constraint is active, the MILP will throttle battery and EV charging to stay within the fuse limit whenever possible.
| Field | Default | Description |
|---|---|---|
max_grid_export_power_kw |
0 (disabled) |
Maximum grid export power in kW — the DNO/inverter export cap for export-limited connections. When set, the MILP hard-bounds per-slot grid export to max_grid_export_power_kw × slot_hours. Set to 0 to disable. |
Unlike the fuse, the export cap is a hard bound (ge[t] ≤ cap × slot_hours)
because it is physically enforced by the inverter/DNO — exceeding it is never
required for feasibility. Battery export and PV export compete for the same cap
through the energy-balance equation, so the optimal plan front-loads battery
export into low-PV slots and tapers it as PV ramps; PV that cannot be exported
at the cap is curtailed. Without the cap the planner overstates export revenue
and can schedule forced battery discharge that displaces PV export.
All fields are prefixed ev_planned_load_.
| Field | Default | Description |
|---|---|---|
ev_planned_load_enabled |
False |
Enable EV planned load integration for the primary EV |
ev_planned_load_connected |
False |
Whether a vehicle is currently plugged in |
ev_planned_load_smart_charging_enabled |
True |
Whether smart EV charging scheduling is permitted |
ev_planned_load_current_soc_pct |
0.0 |
Current EV battery SoC (%) |
ev_planned_load_target_soc_pct |
80.0 |
Target SoC the EV must reach by the deadline (%) |
ev_planned_load_battery_capacity_kwh |
0.0 |
EV battery nameplate capacity (kWh) |
ev_planned_load_charger_power_kw |
0.0 |
Charger AC output power (kW) |
ev_planned_load_charger_efficiency_pct |
100.0 |
Charger efficiency (%) — energy delivered to EV / AC draw |
ev_planned_load_deadline |
None |
Timezone-aware datetime by which charging must be complete |
ev_planned_load_base_load_includes_ev |
Auto (derived) | Whether this EV remains embedded in the normalized planner baseline. An EV with configured power telemetry is removed by HSEM history preprocessing even when the raw CT includes EV load. |
All fields are prefixed ev_second_planned_load_. The schema is identical to the
primary EV fields above:
| Field | Default | Description |
|---|---|---|
ev_second_planned_load_enabled |
False |
Enable EV planned load integration for the second EV |
ev_second_planned_load_connected |
False |
Whether a second vehicle is currently plugged in |
ev_second_planned_load_smart_charging_enabled |
True |
Smart charging permission |
ev_second_planned_load_current_soc_pct |
0.0 |
Current second EV battery SoC (%) |
ev_second_planned_load_target_soc_pct |
80.0 |
Target SoC (%) |
ev_second_planned_load_battery_capacity_kwh |
0.0 |
Second EV battery nameplate capacity (kWh) |
ev_second_planned_load_charger_power_kw |
0.0 |
Charger AC output power (kW) |
ev_second_planned_load_charger_efficiency_pct |
100.0 |
Charger efficiency (%) |
ev_second_planned_load_deadline |
None |
Timezone-aware charging deadline |
ev_second_planned_load_base_load_includes_ev |
Auto (derived) | Same normalized-baseline contract for the second EV, derived independently so two EVs may use mixed accounting. |
All outputs are collected in the PlannerOutput dataclass
(custom_components/hsem/models/planner_outputs.py).
Each PlannedSlot in the output list covers one time interval and carries:
| Field | Unit | Description |
|---|---|---|
start / end |
datetime | Slot boundaries (timezone-aware) |
price.import_price |
currency/kWh | Import price for this slot |
price.export_price |
currency/kWh | Export price for this slot |
solcast_pv_estimate |
kWh | Forecast PV production |
avg_house_consumption |
kWh | Predicted house load (weighted average) |
estimated_net_consumption |
kWh | avg_house_consumption + ev_planned_load_kwh − solcast_pv_estimate (negative = PV surplus) |
batteries_charged |
kWh | Energy scheduled to be stored (after losses) |
batteries_discharged |
kWh | Energy drawn from battery |
grid_import_kwh |
kWh | Grid import this slot |
grid_export_kwh |
kWh | Grid export this slot |
estimated_battery_soc |
% | Estimated SoC at end of slot |
estimated_battery_capacity |
kWh | Usable remaining capacity at end of slot |
ev_planned_load_kwh |
kWh | EV AC load separate from the normalized planner baseline and therefore added to net consumption |
ev_accounted_load_kwh |
kWh | EV AC load still embedded in the normalized planner baseline and subtracted once to recover pure-house demand |
ev_total_planned_load_kwh |
kWh | Total planned EV AC load: ev_planned_load_kwh + ev_accounted_load_kwh. Non-zero whenever EV charging is planned, regardless of base_load_includes_ev |
ev_charger_calculated_power |
W | Primary EV's executable whole-amp AC command; zero means no HSEM authority |
ev_second_charger_calculated_power |
W | Second EV's executable whole-amp AC command |
estimated_cost |
currency | Net grid cost this slot (positive = import, negative = export) |
recommendation |
string | The action chosen for this slot (see below) |
| Value | Meaning |
|---|---|
batteries_charge_grid |
Charge battery from grid (forced by price signal) |
batteries_charge_solar |
Battery is charging from PV surplus |
batteries_discharge_mode |
Battery discharges to cover house load during high-price window |
batteries_discharge_window_mode |
Inside a seasonal discharge window, but the planner solved zero discharge this slot; self-consumption discharge is still allowed |
force_batteries_discharge |
Forced discharge (excess export to grid) |
force_export |
Export price above import/threshold (planner), or — at runtime only — a negative import price combined with a profitable live export price and excess battery export enabled |
ev_smart_charging |
EV charging load is allocated to this slot (planner or runtime resolver) |
batteries_wait_mode |
Battery idle by default; when Wait mode behaviour is set to Self-consumption with reserve, normal household self-consumption is allowed using energy above the planner's required reserve |
time_passed |
Slot has ended (end <= now) — no recommendation applied |
missing_input_entities |
Required HA entities were unavailable when this slot was scheduled |
Recommendations are set in three consecutive layers. Each later layer can override an earlier one only within defined priority rules.
The scheduler assigns the first recommendations during slot population, before candidate scoring. Rules are applied in strict priority order; once a slot has a recommendation it is not changed by later rules in the same layer.
Fixed battery schedules are retired. The former user-configured enable/start/end windows and their
min_price_differencevalues do not feed the MILP. They were removed under issues #860/#873 after their heuristic consumers became inert. References below to a discharge or charge "window" describe contiguous actions produced by the solved plan, not a configured fixed schedule.
Opportunistic grid charge (apply_opportunistic_charge):
| Priority | Condition | Recommendation |
|---|---|---|
| 1 | Import price < 0 | batteries_charge_grid |
| 2 | Import price ≤ depreciation threshold − cycle cost | batteries_charge_grid |
Excess export (apply_excess_export) — only when excess_export_enabled = True:
| Priority | Condition | Recommendation |
|---|---|---|
| 1 | Export price > threshold AND battery has surplus above required_capacity |
force_batteries_discharge |
Seasonal optimisation fill (apply_optimization_strategy) — for all remaining None slots:
| Priority | Condition | Recommendation |
|---|---|---|
| 1 | Export price > import price AND export price ≥ export_min_price |
force_export |
| 2 | Actual PV surplus (estimated_net_consumption_kwh < 0) and battery not full |
batteries_charge_solar |
| 3 | Future force_batteries_discharge slot exists AND battery > required |
batteries_wait_mode |
| 4 | Slot's month is a winter month | batteries_wait_mode |
| 5 | Slot's month is a summer month, actual PV surplus | batteries_charge_solar |
| 5 | Slot's month is a summer month, no PV surplus (zero or positive net consumption) | batteries_discharge_window_mode |
Note:
BatteriesChargeSolaris only assigned when there is a genuine PV surplus (negative net consumption). A small positive house load with zero PV must not be mislabeled as solar charging — that would cause the applier to writeMaximizeSelfConsumptioninstead ofTimeOfUse+ charge TOU (issue #720).
Per-slot season: the seasonal check (priorities 4–5) uses each slot's own calendar month (derived from the slot's
starttimestamp in the local timezone), not the month ofnow. A planning horizon that crosses a season boundary (e.g. Aug 31 → Sep 1) therefore applies the correct seasonal strategy to every slot independently — summer slots get discharge/solar and winter slots get wait-mode, even within the same 48-hour plan.
MILP candidate: the optimizer has already decided every slot, so a slot it left unassigned is one where it chose to leave the battery idle. There the fill skips priorities 2 and 5 and publishes
batteries_wait_mode: it opens no discharge window (issue #1041) and books no solar charge (issue #1158). A PV surplus the optimizer chose to export is therefore exported, not also counted as stored in the battery. The table above applies in full to the non-MILP candidates.
Wait mode behaviour: the
batteries_wait_moderecommendation normally keeps the battery idle. Whenhsem_batteries_wait_mode_behavioris set toself_consumption_with_reserve, the applier switches the inverter toMaximizeSelfConsumption. Discharge is gated by an SoC floor, not a rate cap (issue #942): while the battery holds any surplus above the reserve, the house may draw at the full rated/configured discharge rate — so a real load spike is served from the battery instead of the grid — and once capacity reaches the reserve floor, discharge stops. This applies to the normal case where the plan held the battery fully idle for this slot, not just an edge case (issue #954) — a genuine "Wait" slot always meets that hold condition, so the reserve-floor decision must run instead of the hold default, not only when unheld. This reduces unnecessary grid import while still preserving capacity for the next committed battery action in the selected plan. The reserve is derived from the selected plan's own simulated SoC trajectory (issue #914) — how far it dips before its next actual solved charge — not from a raw forecast PV-surplus scan, so a small or short-lived forecast surplus no longer lets the battery discharge energy the plan needs for a later expensive period. With the dynamic discharge floor enabled the reserve is never below what the floor holds back in the current slot (issue #1200), so the house cannot use the energy the floor set aside. If no reliable reserve can be derived, the applier falls back to strict Wait for that slot. The reserve also decays with time (issue #956): the plan's next committed charge/discharge is only protected in full once it's imminent — a large discharge scheduled hours away no longer locks up nearly the whole battery for self-consumption right now; the reserve ramps up linearly over the last two hours before that action, trusting the next replan to re-tighten it as the action approaches.What the plan shows (issue #1255): the plan books no battery energy on a
batteries_wait_modeslot, so its estimated SoC stays flat across wait slots and the state still reads "Wait". Withself_consumption_with_reservethe real battery can discharge down to the reserve in those slots, so the live SoC falls below the estimate. Every replan starts again from the live SoC. Thewait_mode_self_consumptionattribute ofsensor.hsem_workingmode_sensorsays whether the current wait slot runs as self-consumption, and gives the reserve and the energy above it.
Discharge concentration (concentrate_discharge_on_expensive_slots) runs after the
seasonal fill but before candidate generation. It re-evaluates all discharge-mode
slots and clears the cheapest ones that exceed the battery's capacity, turning them
into batteries_wait_mode (grid-import) so the battery is reserved for the most
expensive slots.
Slots are grouped by calendar day and each day receives its own independent
battery budget (usable_kwh). This correctly accounts for solar recharging between
discharge windows on different days — day N+1's discharge slots do not compete with
day N's for the same capacity pool.
After the winning candidate is selected and the final SoC simulation is
complete, slots with a positive ev_charger_calculated_power or
ev_second_charger_calculated_power command may be re-labelled (issue
#789). Total, accounted, or measured EV load remains physical demand but is
not actuator authority by itself — only a command HSEM actually publishes
may relabel a slot. This prevents a charger still drawing after Smart
Charging is turned off (or a session with no HSEM control, e.g.
fixed_session_only) from recreating its own ev_smart_charging
permission.
| Current recommendation | Positive EV command? | Result |
|---|---|---|
batteries_charge_solar |
Yes | → ev_smart_charging |
batteries_wait_mode |
Yes | → ev_smart_charging |
batteries_discharge_mode |
Yes | → ev_smart_charging (EV label wins) |
batteries_discharge_window_mode |
Yes | → ev_smart_charging (EV label wins) |
batteries_charge_grid |
Yes | Kept — grid charge takes priority |
force_batteries_discharge |
Yes | Kept — forced export takes priority |
force_export |
Yes | Kept |
time_passed |
Yes | Kept |
Applied to the current slot only at hardware-write time. Overrides the planner output with live sensor readings that were unknown at planning time.
| Priority | Condition | Result |
|---|---|---|
| 1 (highest) | Live import price < 0 AND Enable Excess Battery Export is on AND the live export price is available, non-negative, and ≥ max(export_electricity_min_price, batteries_export_min_price) |
→ force_export |
| 2 | Current recommendation = batteries_charge_grid |
Kept — grid charge never overridden |
| 3 | Any EV is actively charging AND the planner allocated EV load to the current slot | → ev_smart_charging |
| — | None of the above | Planner recommendation kept unchanged |
Note (issue #732): a negative import price alone does not make exporting profitable — the export price can be negative too, or the user may have disabled excess battery export. Priority 1 only fires when exporting is both permitted and economically viable; otherwise evaluation falls through to priority 2 and beyond. When priority 1 does fire it always wins — even over an actively charging EV (priority 3). A grid-charge slot (priority 2) is never overridden by an actively charging EV (priority 3).
1. import_price < 0 + profitable export + excess export enabled → force_export [runtime resolver]
2. batteries_charge_grid active → batteries_charge_grid [runtime resolver guard]
3. EV actively charging + planner-allocated EV load → ev_smart_charging [runtime resolver]
──────────────────────────────────────────────────────── resolver boundary ──
4. force_batteries_discharge [excess export, planner]
5. batteries_charge_grid [MILP/opportunistic, planner]
6. batteries_discharge_mode / batteries_discharge_window_mode [solved battery action, planner]
7. force_export [seasonal optimisation, planner]
8. ev_smart_charging [EV load labelling, planner]
9. batteries_charge_solar [solar surplus, planner]
10. batteries_wait_mode [seasonal/idle, planner]
11. time_passed / missing_input_entities
Higher-level groupings of consecutive slots with the same charge or discharge recommendation:
ChargeWindow—start,end,total_energy_kwh,avg_import_price,recommendationDischargeWindow—start,end,total_energy_kwh,avg_export_price,recommendation
| Field | Description |
|---|---|
plan_cost |
Total estimated grid cost for the selected plan (local currency) |
missing_inputs |
List of diagnostic labels for absent input data |
warnings |
Human-readable warning messages about data quality or configuration |
data_quality |
Structured DataQuality report (see below) |
explanation |
PlanExplanation with strategy summary, score, and rejected alternatives |
time_series_index |
TimeSeriesIndex — shared slot grid used internally |
ev_charging_plan |
EVChargingPlan for the primary EV (None when disabled) |
ev_second_charging_plan |
EVChargingPlan for the second EV (None when disabled) |
The PlanExplanation object is designed to be surfaced directly as a HA sensor attribute:
| Field | Description |
|---|---|
selected_strategy |
Short identifier (e.g. "charge_grid_discharge_peak") |
summary |
One-sentence reason for the selected plan |
score |
Savings vs. doing nothing (positive = saves money) |
estimated_total_cost |
Net grid cost for the horizon |
price_spread |
Max − min import price (larger = more arbitrage potential) |
peak_import_price / off_peak_import_price |
Price extremes |
forecast_pv_kwh |
Total PV production for the horizon |
forecast_net_consumption_kwh |
Total load − PV (negative = net solar surplus) |
battery_soc_pct / battery_soc_at_end_pct |
Starting and ending SoC |
constraints |
Active flags (e.g. "winter_month", "excess_export_enabled") |
rejected_plans |
Alternatives with name, reason, and estimated cost |
When one or both EV planned load features are enabled, the planner allocates EV charging demand into slots before the final net consumption is computed. This ensures the home battery planner sees the true net demand and does not misinterpret EV-consumed solar as available for battery charging.
The EV planner is a separate, pure-Python module (planner/ev_planner.py).
It runs once per planning cycle and writes three per-slot load fields to each
PlannedSlot.
EV plans are built from raw inputs only (EV SoC, target SoC, capacity, charger power, deadline, and the per-slot net surplus). They are computed independently of the home battery planner output. The one-pass design prevents circular dependency.
Three fields capture EV load intent precisely:
| Field | Meaning |
|---|---|
ev_planned_load_kwh |
Extra EV AC load added to net consumption — per-EV contributions not embedded in normalized avg_house_consumption. |
ev_accounted_load_kwh |
EV AC load still embedded in the normalized planner baseline and subtracted once to recover pure-house demand. |
ev_total_planned_load_kwh |
Total planned EV AC load: ev_planned_load_kwh + ev_accounted_load_kwh. Always non-zero when EV charging is planned. |
effective_net_load_kwh
= avg_house_consumption
+ ev_planned_load_kwh ← extra load only (zero when base includes EV)
− solcast_pv_estimate
Only ev_planned_load_kwh is added. ev_accounted_load_kwh identifies the
portion still embedded in avg_house_consumption; both MILP reconstruction and
SoC simulation subtract that portion once to recover pure-house demand. A
current live session already removed by preprocessing is classified as planned,
not accounted, so it is never subtracted twice.
The EV planner selects slots in two passes, using net surplus after house consumption as the priority signal:
- Net-surplus slots first — slots where
−estimated_net_consumption > 0(i.e. solar production exceeds house demand) are prioritised. The energy is free for the EV because the house has already consumed its share of solar and the remainder would otherwise be exported. - Cheapest grid-import slots next — among remaining slots, the lowest import price comes first.
Allocation stops when the total energy needed to reach target_soc_pct is
satisfied, or the deadline is reached.
Why net surplus, not raw PV? The house sits between the PV inverter and the EV charger at the AC bus. It always consumes solar first. The EV charger only sees what is left over after house demand is satisfied. Using raw PV would over-estimate the free energy available and schedule more EV load on "solar" slots than is physically available.
The engine processes EV load in three steps:
-
Base net consumption —
populate_net_consumption(slots)is called first, populatingestimated_net_consumption = house − pv(without EV). PV confidence decay (day+1 at 90 %, day+2 at 80 %) is applied before this step so the surplus signal is already conservatively adjusted. -
EV planning — net surplus is derived from step 1:
slot_net_surplus = max(−estimated_net_consumption, 0.0)The EV planner selects slots using this signal and builds charging plans for both EVs. Per-slot loads are accumulated additively (primary + second).
-
Final net consumption —
populate_net_consumption(slots)runs a second time to incorporateev_planned_load_kwhinto the finalestimated_net_consumptionvalues.
The currently active slot is scaled by its remaining duration to avoid over-counting energy in the partially elapsed slot:
slot_remaining_hours = remaining_minutes_in_slot(now, slot_end) / 60.0
max_charge_this_slot = charger_power_kw × slot_remaining_hours × (efficiency / 100)
The CT-position option answers whether the raw live house meter includes EV power. It does not directly answer whether the normalized planner baseline contains a particular EV:
- HSEM utility-meter/history sensors subtract each EV with a configured power entity before accumulating house-load averages.
- Therefore that EV uses
base_load_includes_ev = False, even with an upstream CT, and its load is carried inev_planned_load_kwh. - An EV without removable telemetry may remain embedded and uses
ev_accounted_load_kwh. - Primary and second EVs are classified independently. Mixed planned/accounted contributions are valid in one slot.
- For the current slot, accepted live injection records exactly which measured sessions it removed. Only those EV contributions move out of accounted load. Missing telemetry is not treated as 0 W and cannot prove removal.
ev_total_planned_load_kwh remains the sum of both fields for diagnostics. The
planner does not clamp a negative inferred house load; such a value indicates a
broken accounting contract that tests must expose.
| State | Meaning |
|---|---|
not_connected |
No vehicle plugged in |
smart_charging_disabled |
Feature disabled or smart charging turned off |
fully_charged |
EV has already reached target SoC — no load allocated |
charging |
Slots have been allocated; charging is active or planned |
waiting |
EV is connected but no candidate slots exist before the deadline |
unavailable |
Required config values (capacity or charger power) are zero/missing |
Plan sensors expose the schedule, while current-limit sensors expose commands:
| Entity | Purpose |
|---|---|
sensor.hsem_ev_optimal_charging_plan |
Primary EV plan state and slot details |
sensor.hsem_ev_second_optimal_charging_plan |
Second EV plan state and slot details |
sensor.hsem_ev_charger_current_limit |
Primary whole-amp ceiling for the active slot |
sensor.hsem_ev_second_charger_current_limit |
Second-EV whole-amp ceiling |
Current-limit sensors are unavailable and read fail-closed as 0 until a
successful live coordinator cycle owns a recommendation. A stale restored
positive value is never reused (issue #789).
Both plan sensors share the same attribute schema:
{
"battery_capacity_kwh": 60.0,
"charge_power_kw": 11.0,
"current_soc": 32.0,
"target_soc": 80.0,
"ev_connected": true,
"total_kwh_needed": 28.8,
"deadline": "2026-05-15T07:00:00+02:00",
"charging_slots": [
{
"start": "2026-05-14T10:00:00+02:00",
"end": "2026-05-14T11:00:00+02:00",
"estimated_charged_kwh": 8.5,
"solar_surplus_kwh": 9.2,
"import_needed_kwh": 0.0,
"import_price": 1.25,
"estimated_cost": 0.0
}
],
"planned_load_by_slot": {
"2026-05-14T10:00:00+02:00": 8.5
},
"current_slot_planned_load_kwh": 8.5,
"data_quality": {}
}Current approach (PR #406): The engine runs populate_net_consumption once
before EV planning to derive the per-slot net surplus:
slot_net_surplus = max(−estimated_net_consumption, 0.0)
= max(pv_estimate − avg_house_consumption, 0.0)
This is the correct physical model: the house uses solar first; only what
remains is available to the EV charger at no extra grid cost.
Using estimated_net_consumption as the starting point also ensures that
PV confidence decay (day+1 at 90 %, day+2 at 80 %) is automatically applied
before EV slot selection.
After EV injection, populate_net_consumption runs a second time to produce
final slot values that incorporate ev_planned_load_kwh.
Historical note (PR #397 fix): Before PR #397 the surplus was computed from
slot.estimated_net_consumption which was 0.0 at EV planning time (net
consumption had not been populated yet). Every slot appeared to have zero surplus,
so the EV was always scheduled as grid-import.
The PR #397 workaround derived surplus directly from raw base fields:
surplus = max(slot.solcast_pv_estimate − slot.avg_house_consumption, 0.0)
This was correct but did not yet apply PV confidence decay. PR #406 replaced it
with the pre-populated estimated_net_consumption approach, which is both
conceptually cleaner and more accurate.
When an EV is actively charging, the fixed-demand window depends on whether HSEM can command that charger (issue #789):
- A managed smart-charging session fixes only the remaining part of the current slot as exact (not just lower-bound) demand. The fixed amount is capped by the remaining target and deadline; all later slots remain controllable and are optimised freely by the MILP.
- An unmanaged session (smart charging off, disconnected, or
incompletely configured — HSEM never emits a command for it) is retained
as observed physical demand for up to two executable hours (8×15-minute,
4×30-minute, or 2×60-minute slots), but emits no charger command and
cannot authorise the
ev_smart_charginglabel. - Primary and second EV windows remain distinct, including when one session is managed and the other is not — an unmanaged second EV's window can never make the managed first EV's flexible slots look session-fixed.
managed session (current slot only):
ev_c[0] = min(measured_power × remaining_slot_hours × efficiency,
physical_slot_limit,
remaining_target)
unmanaged session (bounded two-hour window):
ev_c[t] = min(session_charge_kw × slot_hours, ev_max_charge_per_slot)
With a configured, positively connected EV Actual Charging Power Sensor,
HSEM also integrates bounded measured charger power between consecutive
samples. This in-memory delivered-energy credit raises effective SoC until
slow vehicle telemetry catches up, reducing the remaining need on
subsequent plans. Disconnect, restart, an invalid/backwards SoC, an unsafe
session identity, or a battery capacity change clears the credit. Invalid
power readings and excessive sample gaps add no energy and break the
integration interval without erasing credit already validated in the same
identified session (utils/ev_delivered_energy.py).
The cost function scores a candidate plan as a single number. Lower is better — the planner selects the candidate with the minimum score.
total_cost
= grid_import_cost
− export_revenue
+ conversion_loss_cost
+ cycle_cost
score
= total_cost
+ soc_penalty
+ grid_limit_penalty
+ terminal_soc_value
+ battery_target_penalty
battery_target_penalty is zero unless the opt-in house-battery target is
active (issue #1109). It prices a shortfall against the target at the next
target time, for every candidate, and never enters total_cost.
grid_import_cost = Σ (grid_import_kwh[slot] × import_price[slot])
The cost function prices actual grid energy drawn, not stored energy.
If the battery stores x kWh and charge efficiency is e, the grid
import is x / e. Conversion loss is therefore already included in import
cost and must not receive an explicit second charge.
The MILP has no user-configured min_price_difference gate and does not reserve
energy for retired fixed battery schedules. It compares the complete physical
and economic cost directly. Charging one stored kWh at price p_charge and
later discharging it at price p_discharge is profitable only when:
p_discharge × discharge_efficiency
> p_charge / charge_efficiency + cycle_cost_per_kwh
Equivalently:
p_discharge
> (p_charge / charge_efficiency + cycle_cost_per_kwh)
/ discharge_efficiency
For example, with p_charge = 0.297, 97% charge/discharge efficiency, and
cycle_cost_per_kwh = 0.065789, the later import price must exceed approximately
0.384/kWh. A morning peak of 0.363/kWh therefore does not justify overnight
grid charging even though the raw spread (0.066/kWh) is marginally above the
cycle cost: conversion losses erase that margin. Terminal-SoC valuation and
export opportunity cost can alter the full-horizon optimum, but legacy schedule
attributes such as needed_batteries_capacity do not participate.
export_revenue = Σ (grid_export_kwh[slot] × export_price[slot])
Revenue is subtracted from total cost (it reduces the net expense).
Export price floor: export_min_price is a battery-export floor,
not a physical grid limit. The applier no longer throttles the grid
feed-in limit below this price when the export price is non-negative
(issue #767), so surplus PV export is always allowed. The MILP and
discharge scheduler prevent intentional battery-to-grid discharge on
slots where export_price < export_min_price. The cost function
counts PV export revenue at the live export price and only zeroes
battery-destined export revenue on slots blocked by
battery_export_min_price.
Negative export prices are the exception: when exporting costs money, the applier still writes a physical watt limit to block all grid export, including surplus PV.
Export fee (export_fee_per_kwh, issue #925): the raw market export
price is not necessarily net revenue — retailer margin and balancing fees
can make a positive market price a real loss. export_fee_per_kwh
(default 0.0) is subtracted from the export price everywhere export
profitability is decided (the applier's negative-price physical block, the
MILP objective's export-revenue term, and the cost function's mirrored
term), so a positive-but-net-negative price is treated
exactly like a negative raw price. It does not change the
export_min_price/battery_export_min_price floor comparisons above,
which stay on the raw price.
Primary efficiency is fully represented in physical grid flows:
charge_ac_draw = batteries_charged_kwh / charge_efficiency
discharge_ac_delivery = batteries_discharged_kwh * discharge_efficiency
conversion_loss_cost = 0.0
The field remains in diagnostics for schema compatibility only.
Battery depreciation per kWh cycled through the physical cells:
throughput_kwh[slot] = batteries_charged[slot] + batteries_discharged[slot]
cycle_cost = Σ (throughput_kwh[slot] × cycle_cost_per_kwh)
Auto-derived cycle cost (when not explicitly configured):
cycle_cost_per_kwh = purchase_price / (rated_capacity_kwh × expected_cycles)
The displayed recommended threshold is the resolved depreciation cost. Physical efficiency remains visible to the optimizer through AC grid flows rather than a second fixed fee.
Quadratic guard penalties discourage plans that violate SoC bounds:
# Below the floor
if estimated_battery_soc[slot] < min_soc_pct:
violation = min_soc_pct − estimated_battery_soc[slot]
soc_penalty += soc_low_penalty_weight × violation²
# Above the ceiling
if estimated_battery_soc[slot] > max_soc_pct:
violation = estimated_battery_soc[slot] − max_soc_pct
soc_penalty += soc_high_penalty_weight × violation²
These penalties are a soft guard — the SoC simulation already hard-clamps SoC at the hardware limits, so violations are rare in practice.
When a grid power limit is configured, slots that exceed it incur a proportional penalty:
slot_power_kw = grid_import_or_export_kwh / slot_duration_hours
if slot_power_kw > grid_limit_kw:
excess_kwh = (slot_power_kw − grid_limit_kw) × slot_duration_hours
grid_limit_penalty += excess_kwh × grid_limit_penalty_per_kwh
Plans that empty the battery before the horizon ends look artificially cheap because they avoid future discharge costs. The cost function accounts for this by pricing the battery's remaining energy at the end of the horizon.
The terminal SoC penalty (or credit) ensures that two plans with different
ending SoC levels are compared fairly. It values the net change in stored
energy at one end value V (issue #1138):
terminal_soc_value = (initial_kwh − final_kwh) × V
Emptying the battery is not free: the energy left at the end is worth what
it saves after the horizon. V is the lower of two estimates taken from the
last known day of prices: the energy's use at that day's peak (discounted to
90 %) and the cost of storing it again overnight. After a cheap night, a kWh
left at midnight is worth no more than its replacement, so the planner does not
buy energy just to end full.
Because every slot uses the same V, charging and discharging the same energy
inside the horizon adds nothing: the planner decides such a cycle on its real
price spread, losses and cycle wear alone. Charging from PV now or exporting now
and letting a later surplus refill the battery also end at the same SoC, so the
planner simply picks the one that pays more. The per-slot charge-credit caps
that used to handle those cases (#694, #592) are gone.
The planner operates in MILP-only mode. The MILP is the sole optimisation authority; two diagnostic/fail-closed baselines remain for comparison and safety.
| Name | Description |
|---|---|
no_action |
Battery completely idle; diagnostic cost floor only and never eligible to win |
passive |
Absorb available PV surplus; no grid charge or intentional battery discharge |
milp |
Globally optimal LP solution for battery, grid, PV, and any active managed EV loads |
The retired baseline, grid_charge, solar_only, discharge_only,
aggressive, and partial-SoC heuristic families are not generated. Candidate
slots are independently copied, simulated, validated, and scored so no candidate
can mutate another.
After scoring, the selector chooses the lowest valid score, which includes
real cost plus terminal-SoC valuation and safety penalties. A validated MILP is
preferred; passive is the fail-closed fallback if the solve is unavailable or
invalid.
The invariant must always hold:
output.plan_cost == selected_candidate.cost
output.slots == selected_candidate.slots
No post-selection mutation is permitted. If a candidate needs adjusting, it must be re-simulated and re-scored before it can become the output.
HSEM uses a layered safety system to prevent hardware writes when inputs are unsafe or the system is in a degraded state.
DegradedMode (utils/degraded_mode.py) has three values:
| Mode | Hardware writes | Trigger |
|---|---|---|
OK |
Allowed | All inputs present and valid |
Degraded |
Allowed (with warnings) | Non-critical data missing (e.g. tomorrow's prices) |
Error |
Blocked | Critical data missing (battery SoC, house load, working mode) |
Read-only mode is an independent gate, not a DegradedMode value: the
applier checks cfg.read_only (switch.hsem_read_only) separately and
blocks writes regardless of the degraded-mode result.
Critical keywords in missing_inputs block hardware writes:
battery— battery SoC or capacity unavailablehouse_consumption— house load sensor unavailableworking_mode— inverter working-mode select unavailable
Non-critical labels (e.g. tomorrow_price_missing_hours:…) trigger Degraded
mode. The plan is computed and applied, but the coordinator logs a warning and
surfaces the gap in data_quality.
Historical average-sensor availability is preserved: unknown, unavailable,
unparseable, and non-finite states remain missing, while a genuine finite zero
remains valid. Before planning, every future slot's weighted, 1-day, 3-day,
7-day, and 14-day load values must be finite and non-negative.
A complete zero forecast is accepted while finite live house demand is at most
50 W. Above 50 W it is rejected as zero_forecast_with_live_demand. While the
forecast is unsafe, automatic mode does not run or reuse an optimized plan; it
publishes a strict current-slot batteries_wait_mode with primary charge,
discharge, import, and export motion cleared. Manual force mode remains higher
authority. An EV force-charge-now override still applies on top of the hold: the
forced EV charges at its fuse-limited maximum while the home battery stays held
(issue #1103). A managed EV on smart charging follows a grid-only EV-only
fallback plan during the hold: the cheapest import slots before its deadline,
with no PV surplus credited because the house load is unknown (issue #1106).
Recovery or a material future-load change forces a fresh solve, and the reuse
baseline advances only after successful publication.
Registered state events received during a solve advance a coordinator generation. The stale cycle is discarded before publication and one durable follow-up cycle runs from a fresh snapshot, 2 s later so that further events coalesce into it. After two discards in a row, the next cycle publishes even if it is overtaken, then reruns against the newer state. Events that arrive faster than a cycle runs can therefore delay publication but never stop it (issue #1303).
The write-verify applier (WriteVerifyApplier) enforces these gates
before any Huawei Solar service call:
- Checks
cfg.read_only— skip writes ifTrue. - Checks degraded mode — skip writes in
Errormode. - Verifies the inverter is not unloading.
- After writing, reads back the entity state to confirm the change applied.
The MILP's phase-fuse constraint (planner/milp/_phase_fuse.py) protects
against overload using a forecast at solve time. Between solve time and
the hardware write, another appliance on a shared phase can spike load, so
custom_sensors/phase_charge_limiter.py re-checks the newest live
per-phase meter snapshot immediately before every Huawei grid-charge
write. Huawei-only — this repository has no secondary/PowMr inverter.
The fuse trips on current, so the check is done in amps (issue #1119).
Each phase field may be a power sensor (W/kW) or a current sensor (A). A
current reading is used as its magnitude, so it never counts as export; a
power reading is divided by the live phase voltage from the optional
per-phase voltage sensors, or by 230 V when none are configured. A phase
whose sensor has no unit or an unsupported one (e.g. var) counts as
unavailable and blocks the limiter, with a WARNING at most once an hour.
Disabled by default (hsem_phase_aware_charging_enabled = False). When
enabled, requires hsem_main_fuse_phases = 3 and four entities configured:
the three phase power-or-current sensors (power config step) and both
the grid-charge maximum-power number and the battery charge/discharge power
sensor (huawei_solar config step).
For a batteries_charge_grid slot, the limiter:
- Removes the battery's own currently measured charge/discharge contribution from the live phase snapshot (avoiding a feedback loop where a running charge appears to consume its own headroom).
- Computes the maximum grid-charge power that keeps every phase at or below the fuse rating, floored to a 100 W step.
- Writes the lesser of the plan's desired charge power and that live-safe
limit to
hsem_huawei_solar_batteries_grid_charge_maximum_power.
Fails closed to 0 W (rather than leaving a stale positive cap in
place) whenever the feature is enabled and any of the following are true
for a grid-charge slot: main_fuse_phases != 3, main_fuse_amps <= 0, any
phase reading is missing/non-finite or not in a power/current unit, or the battery
charge/discharge-power reading is missing/non-finite. When the feature is
disabled or the slot is not a grid-charge slot, the limiter makes no
changes and the grid-charge-maximum-power entity is left untouched.
A verified downward cap change can take several seconds to physically
settle, during which the live battery-power reading may still show the old
(higher) draw. custom_sensors/phase_charge_transition.py (mixed into the
working-mode sensor) tracks this as a slot-scoped transition:
- While unsettled, the limiter is handed the previous verified cap instead of trusting the live battery-power reading, so a temporarily stale-low reading cannot make the limiter believe more headroom exists than is physically true yet.
- Once settled (both the live cap and the live battery-power reading agree with the new target), the transition clears and the limiter goes back to reading live telemetry directly.
- If it never settles within 45 seconds, the slot fails closed: the grid-charge cap is written as 0 W for the rest of the slot. A dedicated background task fires exactly at the 45-second deadline and forces a fresh hardware-write pass even if nothing else would have triggered one — a stalled or silent telemetry feed cannot indefinitely defer the fail-close.
- Every transition is scoped to one recommendation slot and is cancelled and cleared whenever the config entry reloads, so a reload can never leave a stale transition or a leaked background task behind.
Error mode normally blocks every hardware write, because critical
telemetry is missing and the planner can't safely decide anything new. The
one exception: if HSEM itself armed a Huawei grid charge and then critical
telemetry disappeared mid-charge, HSEM can still safely stop that specific
charge. custom_sensors/applier_emergency_stop.py adds this as a narrow,
downward-only carve-out:
- Ownership comes only from HSEM's own plan history — the current
recommendation being
batteries_charge_grid, or a previous cycle having already latched ownership. A charge armed by a manual TOU schedule or any other source outside HSEM's own recommendations is never touched. - The only write permitted is forcing
hsem_huawei_solar_batteries_grid_charge_maximum_powerto0, verified the same way as every other applier write. - A failed or unverified stop keeps ownership latched, so the next cycle retries automatically instead of giving up.
- Ownership is released as soon as the 0 W write verifies, or as soon as live telemetry independently proves the charge is already stopped — whichever happens first, and independent of whether degraded mode has cleared.
- This exception requires
hsem_phase_aware_charging_enabled = True; it only ever protects the entity that feature already manages.
The DataQuality object on PlannerOutput reports completeness of the planning inputs.
| Field | Type | Description |
|---|---|---|
today_price_missing_hours |
list[int] |
Hours (0–23) with no price data today |
today_pv_missing_hours |
list[int] |
Hours (0–23) with no PV forecast today |
tomorrow_price_missing_hours |
list[int] |
Hours with no price data for tomorrow |
tomorrow_pv_missing_hours |
list[int] |
Hours with no PV forecast for tomorrow |
day2_price_missing_hours |
list[int] |
Hours with no price data for day +2 (horizons spanning 3+ calendar days) |
day2_pv_missing_hours |
list[int] |
Hours with no PV forecast for day +2 |
horizon_has_tomorrow |
bool |
True when horizon extends beyond 24 h |
horizon_days |
int |
Number of calendar days covered (1, 2, or 3) |
load_forecast_ready |
bool |
True when future load provenance and values are safe to optimize |
load_forecast_reason |
str/None |
Machine-readable rejection reason, or None when ready |
load_forecast_missing_hours |
list[int] |
Hour blocks whose average sensors had no stored sample (issue #1110) |
load_forecast_estimated_hours |
list[int] |
Missing hours filled with the conservative neighbour estimate |
is_complete |
bool |
True when price/PV inputs are complete, load_forecast_ready is true, and no load hour is estimated |
data_quality.as_dict() returns a JSON-safe dictionary that can be attached
directly to a sensor's extra_state_attributes:
{
"is_complete": true,
"horizon_has_tomorrow": true,
"horizon_days": 2,
"load_forecast_ready": true,
"load_forecast_reason": null,
"load_forecast_missing_hours": [],
"load_forecast_estimated_hours": [],
"tomorrow_price_missing_hours": [],
"tomorrow_pv_missing_hours": [],
"day2_price_missing_hours": [],
"day2_pv_missing_hours": [],
"today_price_missing_hours": [],
"today_pv_missing_hours": []
}All examples use the following base configuration and were produced by
run_planner on an input built from the stated conditions. The What the
planner does blocks are the plan's slot recommendations, the cost tables are
its rejected_plans, and the Explanation excerpt blocks are the
PlanExplanation it returned, trimmed to the keys the scenario is about.
- Battery: 10 kWh rated, 10 % end-of-discharge floor → 9 kWh usable, no upper SoC limit
- Charge efficiency: 90 % (10 % conversion loss)
- Max charge power: 5 kW (5 kWh/h)
- Battery purchase price 10 000 DKK over 6 000 expected cycles (cycle cost ≈ 0.08 DKK/kWh)
- Horizon: 24 h, 1-hour slots, planned at 00:00
- Prices and PV in local currency (DKK) and kWh
Conditions:
- Month: July (summer month)
- PV forecast: 0→2→6→8→6→4→1→0 kWh/h from 06:00 to 13:00 (27 kWh total)
- House load: 0.5 kWh/h (typical summer light load)
- Import prices: 2.00 DKK/kWh peak (09–11), 0.80 DKK/kWh otherwise
- Export price: 0.10 DKK/kWh
- Battery at start: 20 % SoC (1 kWh above floor)
- Excess export disabled
What the planner does:
Hours 00–07: batteries_wait_mode, with batteries_discharge_mode at 01 and 04
→ the 1 kWh above the floor covers two night hours (0.80 DKK/kWh)
→ the battery is at its floor by 05:00
Hours 07–09: batteries_charge_solar
→ PV surplus (1.5 and 5.5 kWh) charges the battery to 68.5 %
Hours 09–11: batteries_wait_mode
→ PV covers the load at the 2.00 DKK/kWh peak; the surplus exports
Hours 11–12: batteries_charge_solar
→ the last 3.1 kWh of surplus fills the battery to 100 %
Hours 12–24: batteries_wait_mode
→ the evening load (0.80 DKK/kWh) is imported; the battery stays full
Why this plan wins:
The battery empties what it holds into the night load because it will be
refilled by PV for free a few hours later. The PV surplus of the morning is
stored instead of exported at 0.10 DKK/kWh. The evening load is not served
from the battery: a kWh still stored at midnight is valued at the
end value V (≈ 0.97 DKK/kWh here, the cost of recharging
it from the grid overnight), which is more than the 0.80 DKK/kWh an evening
import costs, so the plan keeps the battery for the next day. The passive
candidate exports the surplus that does not fit while the battery is still
emptying and charges the battery later; the diagnostic no_action candidate
imports every night and evening hour.
Cost comparison (rejected_plans):
| Candidate | Cost (DKK) | Note |
|---|---|---|
milp |
5.02 | selected; score 2.18 saved versus do_nothing |
passive |
−1.05 | selector score 1.33 worse than milp |
do_nothing |
7.20 | 24 × 0.5 kWh imported at the hourly price |
Explanation excerpt:
{
"selected_strategy": "charge_solar_discharge_peak",
"winner_name": "milp",
"summary": "Battery will be charged from solar surplus (27.0 kWh forecast) and discharged during peak hours (max 2.000).",
"score": 2.176,
"estimated_total_cost": 5.024,
"constraints": ["summer_month"],
"forecast_pv_kwh": 27.0,
"forecast_net_consumption_kwh": -15.0,
"battery_soc_pct": 20.0,
"battery_soc_at_end_pct": 100.0
}Conditions:
- Month: March (winter month)
- PV forecast: small midday peak (2→3→3→2 kWh/h, 10:00–14:00)
- House load: 1.5 kWh/h
- Import prices:
- 00:00–06:00: 0.25 DKK/kWh (very cheap night tariff)
- 06:00–09:00: 2.50 DKK/kWh
- 09:00–16:00: 1.80 DKK/kWh
- 16:00–21:00: 3.20 DKK/kWh (peak)
- 21:00–24:00: 1.20 DKK/kWh
- Export price: 0.10 DKK/kWh (low, net-metering not attractive)
- Battery at start: 15 % SoC (0.5 kWh above floor)
What the planner does:
Hours 00–02: batteries_wait_mode
Hours 02–04: batteries_charge_grid
→ cheap night rate: 0.25 DKK/kWh import
→ 8.5 kWh charged (9.4 kWh imported at 90 % efficiency)
→ battery full at 04:00; the exact cheap hours are interchangeable
Hours 04–06: batteries_wait_mode (battery full, price still cheap)
Hours 06–09: batteries_discharge_mode (2.50 DKK/kWh morning peak)
→ 4.5 kWh discharged, battery at 53.6 %
Hours 09–10: batteries_wait_mode (1.80 DKK/kWh: imported, battery kept for 3.20)
Hours 10–14: batteries_charge_solar (PV surplus tops the battery up to 89.6 %)
Hours 14–15: batteries_wait_mode
Hours 15–21: batteries_discharge_mode
→ 8.2 kWh discharged through the 3.20 DKK/kWh peak
→ battery reaches the floor at 21:00
Hours 21–24: batteries_wait_mode
Why this plan wins:
The MILP compares the physical cost of storing energy at 0.25 DKK/kWh
(including charge loss and cycle wear) with the import avoided at up to
3.20 DKK/kWh. The large spread is profitable, so the solved MILP charges
exactly the energy the two peaks need, in two of the six cheap hours. The
passive candidate can use the PV but cannot exploit the cheap night tariff;
the diagnostic do_nothing plan pays the full peak import cost.
Cost comparison (rejected_plans):
| Candidate | Cost (DKK) | Note |
|---|---|---|
milp |
17.71 | selected; score 33.29 saved versus do_nothing |
passive |
50.69 | selector score 32.14 worse than milp |
do_nothing |
51.00 | 24 × 1.5 kWh imported at the hourly price |
Explanation excerpt:
{
"selected_strategy": "charge_grid_discharge_peak",
"winner_name": "milp",
"summary": "Battery will be charged from the grid during cheap hours (min 0.250) and discharged during peak hours (max 3.200).",
"score": 33.2886,
"estimated_total_cost": 17.7114,
"price_spread": 2.95,
"constraints": ["winter_month"],
"battery_soc_pct": 15.0,
"battery_soc_at_end_pct": 10.0
}Conditions:
- Month: June (summer month)
- PV forecast: 1→3→7→10→10→10→8→5→3→2→1→0 kWh/h (strong sun, 07:00–19:00, 60 kWh)
- House load: 0.3 kWh/h (light load)
- Export price: 2.80 DKK/kWh (09:00–13:00 midday peak), 0.50 DKK/kWh otherwise
- Import price: 1.80 DKK/kWh (09:00–13:00), 0.80 DKK/kWh otherwise
- Battery at start: 20 % SoC
- Excess export enabled, buffer 10 %, threshold 1.00 DKK/kWh
What the planner does:
Hours 00–04: batteries_wait_mode
Hours 04–06: batteries_charge_grid
→ 4.9 kWh bought at 0.80 DKK/kWh to sell at 2.80 DKK/kWh later
Hours 06–07: batteries_wait_mode
Hours 07–09: batteries_charge_solar
→ PV surplus fills the battery to 100 % before the export window
Hours 09–12: batteries_wait_mode
→ PV covers the load; all surplus (6.7–9.7 kWh/h) exports at 2.80
Hours 12–13: force_batteries_discharge (export_price 2.80 > threshold 1.00)
→ the full 9 kWh usable battery exports together with the PV
Hours 13–16: batteries_charge_solar
→ the battery refills from PV surplus after the export window
Hours 16–24: batteries_wait_mode
→ remaining surplus exports at 0.50; the evening load is imported
Why this plan wins:
The midday export price (2.80 DKK/kWh) exceeds the
excess_export_price_threshold (1.00 DKK/kWh) and the night import price
(0.80 DKK/kWh), so the planner buys from the grid at night, tops up from PV
in the morning, and sells the whole battery in the last hour of the export
window. The export reserve (see MILP export reserve in
docs/planner-spec.md) requires the 10 % buffer to be back before the next
PV-surplus run, and the afternoon PV supplies it. Because the plan both
grid-charges and discharges, the strategy label is
charge_grid_discharge_peak, not force_export: that label is only used when
no grid charge is planned. The passive candidate exports the PV surplus but
never sells the battery.
Cost comparison (rejected_plans):
| Candidate | Cost (DKK) | Note |
|---|---|---|
milp |
−120.92 | selected (net revenue); score 124.04 versus do_nothing |
passive |
−95.67 | selector score 31.87 worse than milp |
do_nothing |
3.12 | load imported; PV export is not counted for this plan |
Explanation excerpt:
{
"selected_strategy": "charge_grid_discharge_peak",
"winner_name": "milp",
"summary": "Battery will be charged from the grid during cheap hours (min 0.800) and discharged during peak hours (max 1.800).",
"score": 124.0433,
"estimated_total_cost": -120.9233,
"constraints": ["summer_month", "excess_export_enabled"],
"forecast_pv_kwh": 60.0,
"forecast_net_consumption_kwh": -52.8,
"battery_soc_at_end_pct": 100.0
}Conditions:
- Month: April (winter/spring boundary, configured as winter)
- PV forecast: modest (1→2→2→2→2→1 kWh/h, 09:00–15:00)
- House load: 1.0 kWh/h
- Import price: 1.20 DKK/kWh flat all 24 hours
- Export price: 0.10 DKK/kWh flat
- Battery at start: 50 % SoC (4 kWh above floor)
What the planner does:
Hours 00–09: batteries_wait_mode, with batteries_discharge_mode in 3 hours
→ the 4 kWh held at start covers three night hours and part of 08
→ no batteries_charge_grid anywhere: nothing to arbitrage
Hours 10–14: batteries_charge_solar
→ 1 kWh/h PV surplus is stored instead of exported at 0.10
Hours 15–24: batteries_wait_mode, with batteries_discharge_mode in 5 hours
→ the stored PV and the remaining charge cover five more hours
→ battery reaches the floor at 24:00
Why this plan wins:
With a flat import price of 1.20 DKK/kWh there is no price arbitrage to
exploit: grid-charging at 1.20 DKK/kWh and discharging later to avoid buying
at 1.20 DKK/kWh would lose the conversion loss and the cycle cost. So the plan
contains no batteries_charge_grid slot, and price_spread is 0. The energy
the battery already holds is a different matter: a kWh kept until midnight is
worth the end value V (≈ 0.97 DKK/kWh here), less than the 1.20 DKK/kWh it
saves when discharged today, so the plan spends it. Which of the equally
priced hours it picks is arbitrary. The PV surplus is stored because 0.10
DKK/kWh export is worth less than the import it later avoids.
Cost comparison (rejected_plans):
| Candidate | Cost (DKK) | Note |
|---|---|---|
milp |
12.75 | selected; score 8.85 saved versus do_nothing |
passive |
18.15 | selector score 1.00 worse than milp |
do_nothing |
21.60 | 24 × 1.0 kWh − 6 kWh PV, at 1.20 DKK/kWh |
Explanation excerpt:
{
"selected_strategy": "charge_solar_discharge_peak",
"winner_name": "milp",
"summary": "Battery will be charged from solar surplus (10.0 kWh forecast) and discharged during peak hours (max 1.200).",
"score": 8.8452,
"estimated_total_cost": 12.7548,
"price_spread": 0.0,
"constraints": ["winter_month", "no_price_spread", "battery_low_at_end"],
"battery_soc_pct": 50.0,
"battery_soc_at_end_pct": 10.0,
"rejected_plans": [
{
"name": "do_nothing",
"reason": "Battery idle would cost 21.6000; selected plan saves 8.8452 over the horizon.",
"estimated_cost": 21.6
},
{
"name": "charge_only_solar",
"reason": "Charging from solar without discharging would leave 10.0 kWh of PV unused during peak demand.",
"estimated_cost": 21.6
},
{
"name": "passive",
"reason": "Higher selector score than selected plan (18.1535 vs 17.1563; Δ = +0.9972).",
"estimated_cost": 18.153511
}
]
}battery_low_at_end is listed because the simulated terminal SoC is the
configured floor; no_price_spread because the max and min import prices are
equal.
Conditions:
- EV plugged in at 08:00, target SoC 80 %, deadline 07:00 next morning
- EV battery: 60 kWh, current SoC 32 % → 28.8 kWh needed
- Charger: 11 kW AC (efficiency 100 %)
- House load: 0.5 kWh/h
- PV forecast: 0→2→8→10→8→4→1→0 kWh/h (peak midday)
- Import price: 0.80 DKK/kWh off-peak, 2.00 DKK/kWh peak (09–13, 17–21)
base_load_includes_ev = False(CT clamp is downstream of EVSE)
What the MILP does:
The MILP co-optimises EV charging, house battery, and grid import/export
simultaneously across all future slots. For pre-deadline slots (t ≤ D),
each ev_c[t] receives a strong negative coefficient (benefit) equal to
ev_penalty_cost = max(p_imp) × max(energy_needed, 1.0) × 10, forcing the LP to
charge the EV.
The LP naturally prefers PV surplus (free) over grid import (costs
p_imp[t]), so it allocates EV charging to high-PV slots first:
Pre-deadline slots (08:00 → 07:00 next day):
10:00–11:00: PV surplus = 8 − 0.5 = 7.5 kWh → EV charges 7.5 kWh (free)
11:00–12:00: PV surplus = 10 − 0.5 = 9.5 kWh → EV charges 9.5 kWh (free)
09:00–10:00: PV surplus = 2 − 0.5 = 1.5 kWh → EV charges 1.5 kWh (free)
Remaining 10.3 kWh → cheapest import slot (00:00–01:00 at 0.80 DKK/kWh)
Post-deadline slots (after 07:00):
ev_c[t] = 0 — hard constraint, no charging allowed
(unless charge_past_target=True, then surplus-PV-only with tiny benefit)
Cost comparison (EV charging cost only):
| Strategy | EV cost (DKK) |
|---|---|
| MILP (solar-first + cheapest import) | 0.80 × 10.3 + 0 × 18.5 = 8.24 DKK |
| Dumb (charge immediately from grid at 2.00 DKK/kWh) | 2.00 × 28.8 = 57.60 DKK |
The PlanExplanation object is published by sensor.hsem_plan_explanation_sensor:
its state is the winning candidate name and every field of the explanation is
an attribute. In the Home Assistant developer tools (States) you can inspect it
directly. This is scenario 2 above:
Entity: sensor.hsem_plan_explanation_sensor
State: milp
Attributes:
selected_strategy: charge_grid_discharge_peak
winner_name: milp
summary: "Battery will be charged from the grid during cheap hours (min 0.250) and discharged during peak hours (max 3.200)."
score: 33.2886
estimated_total_cost: 17.7114
price_spread: 2.95
peak_import_price: 3.2
off_peak_import_price: 0.25
forecast_pv_kwh: 10.0
forecast_net_consumption_kwh: 26.0
battery_soc_pct: 15.0
battery_soc_at_end_pct: 10.0
constraints: [winter_month]
rejected_plans:
- name: do_nothing
reason: "Battery idle would cost 51.0000; selected plan saves 33.2886 over the horizon."
estimated_cost: 51.0
- name: passive
reason: "Higher selector score than selected plan (50.6917 vs 18.5493; Δ = +32.1424)."
estimated_cost: 50.691689
score is the estimated saving of the selected plan versus the no_action baseline:
- Positive score — the plan saves money compared to doing nothing. A score of 33.29 means the planner expects to save 33.29 DKK over the planning horizon.
- Zero or near-zero score — the battery has nothing to add: no stored energy to spend, no PV surplus to store, and no price spread to arbitrage. A flat price alone does not give a zero score (scenario 4).
- Negative score (unusual) — the pre-charge overhead exceeds the discharge benefit within this specific horizon window. This can happen if the horizon ends before the discharge window is fully executed.
These are all the tags _build_explanation and apply_solver_outcome in
custom_components/hsem/planner/engine_explanation.py can emit, in the order
they list them. Exactly one of winter_month and summer_month is always
present; tests/test_planner_guide_explanation_values.py checks that this
guide shows no other tag or strategy:
| Tag | Meaning |
|---|---|
winter_month |
Current month is in months_winter; winter scheduling strategy active |
summer_month |
Not in winter months; summer scheduling strategy active |
no_price_spread |
Max − min import price is near zero; no grid-charge arbitrage |
excess_export_enabled |
Excess export feature is active in config |
battery_disabled |
Rated battery capacity is zero or unavailable |
battery_full |
Initial SoC is at or above the configured maximum |
battery_empty |
Initial SoC is at or below the configured discharge floor |
battery_low_at_end |
Simulated terminal SoC reaches the configured discharge floor |
dynamic_discharge_floor |
The dynamic discharge floor is above the configured minimum SoC and reserves energy |
battery_below_dynamic_floor |
Initial SoC is at or below the dynamic discharge floor; the battery holds less than the reserve |
solver_failed |
The optimiser found no plan this cycle; a fallback plan is published (selected_strategy is solver_failed, issue #1309) |
solver_time_limit |
The MILP solve stopped at its time limit; its plan is published (selected_strategy is solver_time_limit, issue #1309) or, when it scores worse than passive, passive is (solver_time_limit_fallback, issue #1312) |
When battery_below_dynamic_floor is listed, the battery holds less than the
dynamic discharge floor wants to keep for the
hours until the next refill. The plan then spends the battery on the most
expensive of those hours and shows batteries_wait_mode with grid import in
the cheapest ones (issue #1222); it does not sell the battery or charge an EV
from it. That is the floor holding its reserve, not a planner fault:
sensor.hsem_effective_discharge_floor_sensor shows the floor and its
refill_type, and switch.hsem_dynamic_discharge_floor turns the feature
off. On 6.3.x the same tag means the battery is held at its current SoC for
the whole plan (issue #1227).
In legacy mode, the planner predicts house load from a weighted average of 1, 3, 7, and 14-day historical consumption per clock-hour. This works well for regular households but may under- or over-predict when:
- An EV charges on an irregular schedule.
- Seasonal load shifts (e.g. heating vs. cooling) haven't had time to appear in the lookback window.
- Spike days (e.g. a party) pull the average up permanently.
The IQR median-ratio outlier detection algorithm flags anomalous windows, and
a peer-median clamp (issue #592) bounds each window to at most
max(3× the median of the other three windows, 0.15 kWh/h) — so a single
stale window (e.g. a 14d average still holding pre-change EV-charging
nights) cannot dominate the blend when the other three windows agree.
Only the upward side is clamped: a genuine consumption drop flows through
immediately. The ML mode (ridge regression with day-of-week, seasonality,
and outdoor temperature) addresses several of these limitations. See
docs/consumption-prediction.md.
The planner treats all price_points as equally reliable. In practice:
- Today's prices are firm (EDS publishes by ~13:00).
- Tomorrow's prices arrive around 13:00 CET and are typically available before the evening planning run.
- Any hours beyond the published data (e.g. tomorrow before ~13:00) are estimated.
Missing price data is surfaced in data_quality and triggers Degraded mode.
Since issue #1002 the planner fills price-missing slots with the same-hour
price from the nearest earlier day that has data (with quarter-hourly prices:
the same quarter of that hour, issue #1219), instead of planning against
a fictitious 0.0 ("free energy") price; only hours missing on every earlier
day still fall back to 0.0. The estimate is always flagged in
data_quality.*_price_missing_hours, so diagnostics reflect the true data
coverage.
Slots marked time_passed are frozen. If the morning plan assumed 5 kWh of PV
that didn't materialise (cloudy day), the afternoon plan starts fresh from the
current SoC but does not retroactively account for the morning shortfall.
export_min_price now gates intentional battery-to-grid discharge, not
the entire grid connection point. Surplus PV export is always allowed
when the export price is non-negative. The exception is negative export
prices, where the applier still writes a physical block because
exporting then costs money.
The planner applies a single import price and export price per slot. It does not model time-of-use (TOU) tariffs with multiple simultaneous price components (e.g. capacity tariffs, network fees, or spot + fixed-premium structures). These can be factored in manually by adjusting the import price values fed to the planner.