Skip to content

Latest commit

 

History

History
1983 lines (1586 loc) · 116 KB

planner-guide.md

File metadata and controls

1983 lines (1586 loc) · 116 KB

HSEM Planner — Technical Guide

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.


Table of contents

  1. Overview
  2. Planning inputs
  3. Planning outputs
  4. EV planned load integration
  5. Cost function
  6. Candidate generation and selection
  7. Safety modes
  8. Data quality diagnostics
  9. Scenario examples
  10. Reading the plan explanation
  11. Known limitations

Overview

The HSEM planner is a forward-looking, cost-minimising battery scheduler. Every time the coordinator runs (typically every minute) the planner:

  1. Reads the current battery state, electricity prices, and PV forecast.
  2. Generates a time grid of slots covering the planning horizon (12, 24, 36, or 48 hours).
  3. Populates each slot with expected house load, PV production, and prices.
  4. Evaluates several candidate strategies (charge from grid, discharge only, solar only, etc.).
  5. Scores every candidate with the cost function.
  6. Writes the lowest-cost valid plan to the HourlyRecommendation objects 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.


Planning inputs

All inputs are collected in the PlannerInput dataclass (custom_components/hsem/models/planner_inputs.py).

Temporal context

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

Battery hardware

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)

Battery economics

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.

Dynamic discharge floor

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.

Consumption prediction

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.

Price data

Field Type Description
price_points list[PricePoint] Hourly import/export prices

Each PricePoint carries:

  • hour — 0-based clock-hour
  • import_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.

PV forecast

Field Type Description
solcast_slots list[SolcastSlot] Forecast PV production, per hour or per planner slot

Each SolcastSlot carries:

  • hour — 0-based clock-hour
  • pv_estimate — average PV power (kW) over the entry's period. For an hourly entry that is also the hour's energy in kWh
  • day_offset — whole days from the planning midnight
  • slot_in_day — optional index of the planner slot within its day. None means 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.

Solar forecast auto-correction

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.

Current-slot live sampling

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).

Excess export and grid controls

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.

House-battery target SoC by deadline (issue #1109)

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_delta shows 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.

Seasonal configuration

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

Main fuse / tariff protection

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.

Grid export power cap (issue #726)

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.

EV planned load — primary EV

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.

EV planned load — second EV

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.

Planning outputs

All outputs are collected in the PlannerOutput dataclass (custom_components/hsem/models/planner_outputs.py).

Per-slot decisions (slots)

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)

Recommendation values

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

How recommendations are assigned — priority layers

Recommendations are set in three consecutive layers. Each later layer can override an earlier one only within defined priority rules.


Layer 1 — Planner engine (pre-slot population)

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_difference values 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: BatteriesChargeSolar is 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 write MaximizeSelfConsumption instead of TimeOfUse + 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 start timestamp in the local timezone), not the month of now. 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_mode recommendation normally keeps the battery idle. When hsem_batteries_wait_mode_behavior is set to self_consumption_with_reserve, the applier switches the inverter to MaximizeSelfConsumption. 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_mode slot, so its estimated SoC stays flat across wait slots and the state still reads "Wait". With self_consumption_with_reserve the 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. The wait_mode_self_consumption attribute of sensor.hsem_workingmode_sensor says 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.


Layer 2 — EV planned load labelling (engine, post-simulation)

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

Layer 3 — Runtime resolver (resolve_current_recommendation)

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).


Summary: full priority stack (highest → lowest)
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

Charge and discharge windows (charge_windows, discharge_windows)

Higher-level groupings of consecutive slots with the same charge or discharge recommendation:

  • ChargeWindow — start, end, total_energy_kwh, avg_import_price, recommendation
  • DischargeWindow — start, end, total_energy_kwh, avg_export_price, recommendation

Plan metadata

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)

Plan explanation (explanation)

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

EV planned load integration

Overview

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.

No circular dependency

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-field EV load model

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.

Net load formula with EV

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.

Slot selection strategy

The EV planner selects slots in two passes, using net surplus after house consumption as the priority signal:

  1. 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.
  2. 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.

Engine execution order

The engine processes EV load in three steps:

  1. Base net consumption — populate_net_consumption(slots) is called first, populating estimated_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.

  2. 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).

  3. Final net consumption — populate_net_consumption(slots) runs a second time to incorporate ev_planned_load_kwh into the final estimated_net_consumption values.

Partial current-slot scaling

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)

Raw meter versus normalized baseline

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 in ev_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.

EV plan states

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

HA sensor entities

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": {}
}

Net surplus model (and historical notes)

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.


Session-aware EV demand

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_charging label.
  • 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).


Cost function

The cost function scores a candidate plan as a single number. Lower is better — the planner selects the candidate with the minimum score.

Formula

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_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.

Grid-charge break-even

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

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.

Conversion loss compatibility field

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 cycle cost

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.

SoC penalties

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.

Grid limit penalty

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

Terminal SoC accounting

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.


Candidate generation and selection

The planner operates in MILP-only mode. The MILP is the sole optimisation authority; two diagnostic/fail-closed baselines remain for comparison and safety.

Candidate strategies

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.

Selection

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.


Safety modes

HSEM uses a layered safety system to prevent hardware writes when inputs are unsafe or the system is in a degraded state.

Degraded mode levels

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 vs. non-critical missing data

Critical keywords in missing_inputs block hardware writes:

  • battery — battery SoC or capacity unavailable
  • house_consumption — house load sensor unavailable
  • working_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.

Load-forecast readiness

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).

Safety gate behaviour

The write-verify applier (WriteVerifyApplier) enforces these gates before any Huawei Solar service call:

  1. Checks cfg.read_only — skip writes if True.
  2. Checks degraded mode — skip writes in Error mode.
  3. Verifies the inverter is not unloading.
  4. After writing, reads back the entity state to confirm the change applied.

Live phase-aware grid-charge safety limiter (issue #831)

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:

  1. 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).
  2. Computes the maximum grid-charge power that keeps every phase at or below the fuse rating, floored to a 100 W step.
  3. 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.

Transition safety: feedback-free floor + 45-second fail-closed deadline

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 emergency stop for an HSEM-owned grid charge (issue #840)

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_power to 0, 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.

Data quality diagnostics

The DataQuality object on PlannerOutput reports completeness of the planning inputs.

Fields

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

Home Assistant attribute serialisation

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": []
}

Scenario examples

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

Scenario 1: Summer day — high PV surplus

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
}

Scenario 2: Cheap night price — grid charge opportunity

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
}

Scenario 3: High PV day — excess export opportunity

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
}

Scenario 4: Flat price day — no arbitrage value

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.


Scenario 5: EV charging — MILP co-optimisation

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

Reading the plan explanation

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

Understanding score

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.

Understanding constraints

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).


Known limitations

Consumption prediction: legacy mode is averaged, not model-based

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.

Prices are assumed known for the full horizon

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.

No intra-day re-planning of past slots

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.

Battery export floor, not a grid throttle

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.

Single-zone tariff model

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.