The MILP solver (planner/milp_optimizer.py) finds the globally optimal battery charge/discharge schedule using scipy's HiGHS linear programming solver. It is the sole optimisation authority. The only accompanying candidates are no_action (diagnostic only) and passive (fail-closed PV-only fallback); the former heuristic candidate family is retired. scipy/numpy are declared in manifest.json's requirements, so Home Assistant installs them automatically on setup; the "unavailable" fallback below is a defensive path, not the expected steady state.
flowchart TD
A[Engine passes slots with populated prices / PV / consumption]
B[Identify future active slot indices past vs fixed-zero]
C[Build per-slot data arrays: p_imp, p_exp, net_load, base_load, pv_avail]
D{EV configs provided?}
E[Rebuild net_load without pre-computed EV planned loads]
F[Keep fixed EV planned load in net_load]
G[Build objective vector c_obj: import cost, export revenue, cycle cost, terminal-SoC credit, SoC penalties, EV deadline penalties, EV pre-deadline benefit, EV charge-past-target benefit]
H[Build equality constraints A_eq: energy balance per slot inc. EV charger load]
I[Build inequality constraints A_ub: SoC recurrence soft bounds, mutual exclusion, cycle cost auxiliary m >= ec/ed, EV cumulative SOC, EV deadline target, EV post-deadline zero-charge, EV surplus-only]
J[Build variable bounds: ec, ed capped; pv fixed to actual surplus; penalties >= 0]
K[linprog method = highs, timeout = 2s]
L{Solution found?}
M[Decode solution: ec, ed, gi, ge, pv, m, penalties, EV charging]
N[Write recommendations to output slots BatteriesChargeGrid, BatteriesChargeSolar, BatteriesDischargeMode, ForceBatteriesDischarge]
N2[Write LP-derived energy flow fields: batteries_discharged_kwh, grid_import_kwh, grid_export_kwh — these are the source of truth]
O[Compute penalty violation diagnostics]
P[Return slots and diagnostics]
Q[Return None: solver failed or no future slots]
A --> B --> C --> D
D -->|Yes| E --> G
D -->|No| F --> G
G --> H --> I --> J --> K --> L
L -->|Yes| M --> N --> N2 --> O --> P
L -->|No| Q
For each slot t ∈ 0…n-1 the LP variable vector x contains eight decision variables:
| Offset | Variable | Name | Description | Bounds |
|---|---|---|---|---|
0 |
ec[t] |
ec_off |
Energy charged and stored in battery this slot (kWh) | [0, max_charge_per_slot] |
n |
ed[t] |
ed_off |
Energy discharged from battery this slot (kWh) | [0, max_discharge_per_slot] |
2n |
gi[t] |
gi_off |
Grid import this slot (kWh) | [0, ∞) |
3n |
ge[t] |
ge_off |
Grid export this slot (kWh) | [0, ∞) — hard-capped to max_grid_export_power_kw × slot_hours when the export cap is configured (issue #726) |
4n |
pv[t] |
pv_off |
PV surplus available in slot t (kWh) | [pv_avail[t], pv_avail[t]] (fixed) |
5n |
m[t] |
m_off |
Auxiliary variable ≥ max(ec[t], ed[t]) for cycle cost (kWh) | [0, ∞) |
6n |
s_max_pen[t] |
s_max_off |
SoC upper penalty — kWh by which state of charge exceeds usable_kwh |
[0, ∞) |
7n |
s_min_pen[t] |
s_min_off |
SoC lower penalty — kWh by which state of charge drops below 0 | [0, ∞) |
The state of charge soc[t] is not an explicit variable — it is derived from the forward recurrence:
Penalty variables s_max_pen and s_min_pen prevent infeasibility when the initial SoC lies outside [0, usable_kwh]. Their objective coefficient is extremely high (max(p_imp) × 100), so they are only used when the initial state is physically out of bounds.
When one or more active EVs are provided, the variable vector expands to:
where E is the number of active EVs.
| Offset | Variable | Name | Description | Bounds |
|---|---|---|---|---|
8n + i·n |
evN_c[t] |
EV N DC-side charge per slot (kWh) | [0, evN.max_charge_per_slot] |
|
8n + E·n + i |
evN_pen |
EV N deadline target slack (kWh shortfall) | [0, ∞) |
The EV charger AC load entering the energy balance equation is evN_c[t] / charger_efficiency.
When an EV's charge_past_target flag is True (EV already at user-configured target SoC, allow_charge_past_target_soc enabled, SoC < 100 %):
- The deadline constraint is suppressed (
deadline_slot = None) — no grid import pressure - The surplus-only constraint is added (see Constraints below)
- An avoided-future-import-cost benefit (
future_value_per_kwh, issue #630) is added to the objective, falling back to a tiny fixed tiebreaker when no future price data is available (see Objective function below)
When an EV has a deadline and charge_past_target=False (normal mode):
- Pre-deadline slots (
t \u2264 D, issue #797): no direct per-kWh benefit onev_c[t]; the deadline slack penalty alone already prices meeting the target far above any real import price, so charging competes only against its own real grid/PV opportunity cost. A tiny tiebreak cost (_EV_TARGET_ENERGY_TIEBREAK_COST = 1e-7/kWh) nudges the LP toward the smallest executable energy that clears the target-cap constraint. - Post-deadline slots (
t > D): hard constraintev_c[t] = 0— charging is forbidden
A managed EV (not fixed_session_only) gets a solver-native semi-integer
amp variable ev_{i}_amps[t] (HiGHS integrality type 3: zero, or an integer
in [min_amp, rated_amp]), declared as the last blocks in
build_milp_column_layout (planner/milp/_layout.py), after every physical
and fuse block. An equality constraint per slot links it to the DC charge
variable:
so the solved allocation is always directly executable — _write_results.py
publishes it verbatim, with no post-solve concentration/quantization pass.
See planner/milp/_ev_amp_lattice.py and the Discharge permission and
whole-amp lattice section of docs/planner-spec.md for the full
constraint model, including the conditional ev_{i}_on binary that caps
primary battery discharge while a permission-restricted EV charges.
When main_fuse_amps > 0, the variable vector expands further:
| Offset | Variable | Name | Description | Bounds |
|---|---|---|---|---|
| after EV vars | gi_pen[t] |
gi_pen_off |
Grid import fuse penalty — kWh exceeding the main fuse rating | [0, ∞) |
The stage-2 solve of the opt-in house-battery target adds one column,
battery_target_penalty (width 1, [0, ∞)), the shortfall in kWh against the
target at slot gi[t] bounds with the
stage-1 import (fixed for ge[t] lower bound to the stage-1 battery-origin export for
The max grid import per slot is converted from amps to kWh/slot:
where phases is the electrical phase count (1 or 3, default 3).
This assumes balanced load at 230 V phase-to-neutral per phase.
The penalty uses the same high coefficient as SoC penalties (max(p_imp) × 100), ensuring the solver only exceeds the fuse limit when physically unavoidable (e.g. house base load alone exceeds the rating). When main_fuse_amps is None or 0, no variables or constraints are added — behaviour is unchanged.
Plus EV deadline penalties (undiscounted — deadline is a hard commitment):
Where:
| Symbol | Description |
|---|---|
| Time discount per slot: |
|
Grid import price (currency/kWh), sanitised to max(p_imp_raw[t], 0) (issue #655). |
|
Grid export price (currency/kWh). Before solving, p_exp is sanitised by clamping to min(p_exp, p_imp) to prevent an unbounded LP when p_exp > p_imp in any slot (issue #635). The legacy min_export_price clamp has been removed because the applier no longer physically blocks grid export below this price (issue #767). The export-revenue coefficient above actually uses p_exp_net[t] = p_exp[t] - export_fee_per_kwh (issue #925, default fee 0.0) — a fixed retailer margin/balancing-fee deduction so a positive-but-net-negative price is valued the same as a genuinely negative price. This netting is local to the objective; the battery-export floor mask (below) still uses the raw p_exp. |
|
| Battery cycle cost per kWh: |
| cost_helpers.terminal_end_value_from_last_day, issue #1138). One value for every slot, so an in-horizon cycle that leaves the end energy unchanged adds zero. |
| future_value_per_kwh — avoided-future-import valuation (issue #630), or a
There is no fixed-schedule min_price_difference constraint in the MILP. The
solver chooses grid charging only when its full-horizon objective improves. For
a simple charge-now/discharge-later comparison, one stored kWh is profitable
when:
or equivalently:
The formula includes the actual AC energy needed to store one kWh, the AC energy
delivered by one discharged kWh, and cycle wear. A raw price spread that only
matches
Plus EV pre-deadline benefit (undiscounted, per EV
This direct benefit on pre-deadline slots ensures the LP always prefers charging over paying the deadline penalty. Post-deadline slots (charge_past_target=True.
The pre-deadline benefit block and the charge-past-target benefit block are mutually exclusive by design. The LP construction guards the pre-deadline block with and not ev.charge_past_target, mirroring the post-deadline zero-charge constraint's guard. An EV in charge-past-target mode never receives the large penalty-driven benefit — only the future_value_per_kwh benefit (or its tiebreaker fallback) applies. This exclusion is enforced directly in the LP construction rather than relying on caller discipline in engine_core.py.
Plus EV charge-past-target benefit (discounted, per charge-past-target EV
EVConfig.future_value_per_kwh: the avoided cost of importing the same energy later, computed as confidence_factor × mean(import_price) over the next 24 hours (ev_future_charge_value_per_kwh in candidate_selector.py; the house battery's terminal SoC uses a similar avoided-cost value, confidence_factor defaults to 0.9 and is configurable per EV (hsem_ev_past_target_confidence_factor / hsem_ev_second_past_target_confidence_factor) to discount for uncertainty in whether the EV will actually need the extra energy before its next charge.
Because future_value_per_kwh is None), the MILP falls back to a tiny fixed tiebreaker (
For each slot
-
base_load[t]=$\max(\operatorname{net\_load}[t], 0)$ — demand the grid/battery must satisfy (kWh) -
net_load[t]=avg_house_consumption[t] - solcast_pv_estimate[t](when EV co-optimisation active) -
pv_avail[t]=$\max(-\operatorname{net\_load}[t], 0)$ — PV surplus fixed to thepv[t]variable bounds - EV charger efficiency re-scales DC-side charge to AC grid/PV load
SoC upper bound (soft):
SoC lower bound (soft):
Mutual exclusion — no simultaneous charge + discharge:
Cycle cost auxiliary — forcing
EV cumulative SoC upper bound (per EV v):
EV deadline target (soft, per EV v):
EV post-deadline zero-charge (hard, per EV v with deadline and charge_past_target=False):
This hard constraint prevents any EV charging after the deadline unless
charge_past_target=True (surplus-PV-only mode).
EV surplus-only constraint (per charge-past-target EV v, per slot t):
This constraint ensures charge-past-target EVs only consume genuine PV surplus — never battery discharge or grid import. It is added for EVs where charge_past_target=True (EV already at user-configured target SoC but allow_charge_past_target_soc is enabled and SoC < 100 %). The bound is pro-rated by the remaining fraction of a partly elapsed live slot (issue #1012).
Shared battery-first budget (issue #775, #1015):
A per-slot surplus cap alone cannot stop a past-target EV from taking surplus the house battery would have stored while the battery refills from cheap grid — the EV would then draw from grid in all but name. And because ec[t] is the battery's total charge (grid- and PV-sourced energy share one column), a shared row $ec[t] + \sum_v \mathrm{ev\_c}v[t]/\eta{\mathrm{charger}}^{(v)} \le S[t]$ caps all battery charging at the surplus, so the battery could not grid-charge while such an EV was plugged in.
The production path solves the counterfactual in two stages (planner/milp/_past_target_reservation.py). Stage 1 solves without any charge-past-target EV and records
The battery then needs no row and stays free to grid-charge. The second solve runs only while a charge-past-target EV is active. A direct solve_milp call without a reservation keeps the conservative shared row above. See planner-spec.md Battery-first for charge-past-target.
House-battery target (soft, stage 2 only, issue #1109):
with planner/milp/_battery_target.py).
Main fuse grid import limit (soft):
For each slot main_fuse_amps > 0:
The penalty variable gi_pen[t] absorbs any excess at high cost (p_fuse), preventing infeasibility when house base load alone exceeds the fuse rating. When main_fuse_amps is None or 0, this constraint is not added.
Grid export power cap (hard, issue #726):
For each slot max_grid_export_power_kw > 0:
This is a hard bound on the ge[t] variable — unlike the fuse it needs no penalty variable because the cap is physically enforced by the inverter/DNO, so exceeding it is never required for feasibility. Battery export and PV export compete for the same cap through the energy-balance equality, so the LP naturally front-loads battery export into low-PV slots and tapers it as PV ramps; PV that cannot be exported at the cap is handled by the free curt[t] variable. When max_grid_export_power_kw is None or 0, ge[t] remains unbounded above (identical to previous behaviour).
Where
Conditional battery-export reserve:
When excess export is enabled and the discharge buffer is positive, explicit
battery-origin export bx[t] activates binary z_export[t]. Every slot in one
contiguous forecast PV-surplus run uses the checkpoint derived from that run's
final slot: immediately before the next distinct surplus run, or horizon end.
bx[t] <= max_discharge_per_slot * z_export[t]
SoC[checkpoint[t]] >= buffer_kwh - usable_kwh * (1 - z_export[t])
Aggregate export is source-constrained by direct PV plus battery AC export, so the reserve and no-export mode can suppress battery export without restricting ordinary PV export. Grouping changes only checkpoint preprocessing and adds no variables or rows beyond the per-slot source/reserve formulation.
Battery export forecast reserve (issue #807, Stage 1):
An independent, opt-in reserve (hsem_batteries_forecast_reserve_pct, 0–50 %,
default 0 = disabled) adds one row per slot, active whenever
forecast_reserve_kwh > 0:
SoC[t] >= forecast_reserve_kwh - usable_kwh * (1 - z_export[t])
Unlike the checkpoint reserve, this row is indexed by the same slot t, so
it binds the SoC immediately after the exporting slot itself — a later PV or
grid refill can never justify spending it first. forecast_reserve_kwh is
the configured percentage above the hardware discharge floor, which is the
MILP's inventory origin. The dynamic discharge floor is a separate per-slot
lower bound on stored energy above that same origin (issue #1188), so the
two never add up: the higher one binds. See docs/planner-spec.md § Battery export forecast
reserve for the full derivation.
Battery export minimum price floor (issue #752):
For each slot battery_export_min_price > 0 and the slot's raw p_exp[t] < battery_export_min_price (evaluated before the min_export_price and export-≤-import clamps):
This is the per-slot, soft-switch companion to the global no_export cap. Where no_export blocks battery export on every slot when excess_export_enabled = False, the floor blocks it only on slots where the user's explicit per-slot price guard is unsatisfied. The battery can still serve house load on the blocked slot — it just cannot intentionally export to the grid there. Above the floor the optimizer is free to decide whether exporting is worthwhile; reaching the threshold does not automatically trigger export.
Scope: the floor applies only to intentional battery-to-grid export (ForceBatteriesDischarge). It does not affect normal battery self-consumption, battery discharge for house load, direct PV export (ge is not capped — only ed is), or PV charging of the battery.
Evaluation on the raw p_exp is essential: the user's battery_export_min_price floor must be honoured even when the recommended_threshold (auto-calculated cycle wear) or the export_min_price battery-export floor are lower. Selecting the larger of the three floors would force every user to overwrite their own threshold when they want a stricter guard, defeating the purpose of the dedicated setting.
The non-MILP apply_excess_export path enforces the same floor by requiring export_price >= max(export_min_price, recommended_threshold, battery_export_min_price) for any slot it would otherwise label ForceBatteriesDischarge.
When battery_export_min_price = 0 (default), no slot is ever blocked by the floor — the behavior is entirely backward compatible with the pre-#752 PLP.
Before the LP is built, two sanitisation steps are applied to p_exp to prevent solver instability and maintain consistency with physical constraints:
Slots where p_exp < max(min_export_price, battery_export_min_price) do
not have their export price clamped to 0. Instead, the battery discharge
variable ed[t] is capped to base_load[t] / η_dis so the battery can
serve house load but cannot intentionally export to the grid. This keeps
surplus PV export possible (the applier no longer throttles the grid
feed-in limit for non-negative prices, issue #767) while still preventing
battery-to-grid discharge below the configured floor. Negative export
prices are handled by the applier, which writes a physical watt limit to
block all grid export when exporting costs money.
p_exp is clamped to never exceed p_imp for the same slot:
Without this, slots where p_exp > p_imp create an unbounded LP (HiGHS status=3). gi[t] and ge[t] are both [0, ∞) and linked only through the per-slot energy-balance equality, so the LP can drive both to infinity (import cheap, export expensive) while the terms cancel. A single such slot causes solve_milp() to return None for the entire horizon, silently falling back to weaker heuristic candidates.
This condition occurs in practice whenever negative import spot prices coincide with positive export tariffs (common in DK/DE/NL markets during high wind/solar hours), or when asymmetric import/export grid fees create an apparent export-price premium.
The clamp is economically correct and capping the achievable arbitrage spread removes the unbounded direction without changing any other optimisation behaviour.
The site balance uses ec[t] / charge_efficiency as AC charge draw and
ed[t] * discharge_efficiency as AC discharge delivery. Consequently the
gi[t] and ge[t] money coefficients price conversion loss exactly once. A
separate (1-efficiency) coefficient would double-count it.
The retained conversion_loss_cost and
discharge_loss_cost_destination_aware fields remain for schema compatibility
and are always zero. The LP and authoritative scorer use the same single-count
physical model.
After solving, the solution is decoded into slot recommendations and energy flow fields:
flowchart TD
A[LP solution: ec, ed, gi, ge]
B{ec > threshold AND ed > threshold?}
C[Numerical tolerance conflict: resolve by net profit]
D{Round-trip profitable?}
E[Keep ec, zero ed]
F[Zero both]
G{ec > threshold?}
H{PV surplus available?}
I[BatteriesChargeSolar]
J[BatteriesChargeGrid]
K{ed > threshold?}
L{Grid export > 0 AND price >= min_export_price?}
M[ForceBatteriesDischarge]
N[BatteriesDischargeMode]
N2[Write LP energy flow fields to ALL future slots:
batteries_discharged_kwh, grid_import_kwh, grid_export_kwh]
O[Write EV charge decisions]
P[Recompute estimated_net_consumption and estimated_cost per slot]
Q[Compute penalty violation diagnostics]
A --> B
B -->|Yes| C --> D
D -->|Yes| E --> G
D -->|No| F --> G
B -->|No| G
G -->|Yes| H
H -->|Yes| I --> N2
H -->|No| J --> N2
G -->|No| K
K -->|Yes| L
L -->|Yes| M --> N2
L -->|No| N --> N2
K -->|No| N2
N2 --> O --> P --> Q
The MILP now writes all per-slot energy flow fields directly from the LP solution, making them the source of truth:
| Field | LP variable | Description |
|---|---|---|
batteries_discharged_kwh |
ed[t] |
Energy discharged from battery (kWh) |
grid_import_kwh |
gi[t] |
Grid import (kWh) |
grid_export_kwh |
ge[t] |
Grid export (kWh) |
These are populated for every future slot (zero for idle slots). The
SoC simulation (:func:~soc_simulation.simulate_soc) must be called with
milp_prepopulated=True for MILP-sourced candidates so it preserves
these values verbatim instead of re-deriving a different (greedy)
allocation from the recommendation label and net demand.
The raw CT-position flag applies only to current live-meter reconciliation.
Before MILP construction, HSEM derives base_load_includes_ev independently per
EV from the normalized house baseline: configured EV power telemetry is removed
by HSEM history preprocessing, so that EV is not embedded even when the raw CT
is upstream of the EVSE. Current-slot live injection additionally records which
measured sessions it actually removed. The MILP net-load rebuild, writeout, and
SoC simulation all use this same per-EV split; no site-wide any(...) shortcut
is valid for two-EV accounting.
| Field | Source |
|---|---|
ev_planned_load_kwh |
Sum of EV AC contributions not embedded in normalized house consumption |
ev_accounted_load_kwh |
Sum of EV AC contributions still embedded in normalized house demand |
ev_total_planned_load_kwh |
Total AC load (sum of planned + accounted) |
ev_charger_calculated_power |
Target AC power (W) for primary EV |
ev_second_charger_calculated_power |
Target AC power (W) for second EV |
The pure-house load used for battery discharge is
avg_house_consumption_kwh - ev_accounted_load_kwh; it is not clamped to zero.
This lets the battery cover genuine house demand while an EV charges, subject to
the existing permission and positive per-EV discharge ceiling, while EV demand
itself remains grid/PV supplied.
After the MILP (or baseline) winner is selected, the engine runs a final pass over all slots to ensure consistency:
-
Power recomputation:
ev_charger_calculated_poweris recomputed from the actual per-slot EV AC load (ev_planned_load_kwh + ev_accounted_load_kwh). For the current (partially elapsed) slot the remaining time is used as the divisor. This ensures the power field always matches the load, even when the baseline candidate wins (bypassing the MILP's own power calculation). -
Minimum power floor: If the computed AC power is below
charger_min_power_w(default 1380 W = 230 V × 6 A), the charger physically cannot start. The slot's EV fields are zeroed out:ev_charger_calculated_power = 0ev_planned_load_kwh = 0,ev_accounted_load_kwh = 0,ev_total_planned_load_kwh = 0recommendationcleared if it wasev_smart_chargingestimated_net_consumption_kwhandestimated_cost_currencyrecomputed without EV load
-
EV plan rebuild: When the MILP wins, the
EVChargingPlanobjects (used by theev_optimal_charging_plansensor) are rebuilt from the winning slots viarebuild_ev_plan_from_slots(). This ensures the sensor displays the MILP's actual decisions, not the EV planner's pre-MILP estimate.
- Linear relaxation: Binary charge/discharge flags are relaxed to continuous because the mutual-exclusion constraint and per-slot power caps already prevent simultaneous charge + discharge in the optimal solution.
- Deterministic inputs: All forecasts (prices, PV, load) are treated as known with certainty — no stochastic programming.
- Cycle cost proxy: The
m[t] = max(ec[t], ed[t])formulation counts the larger of charge or discharge per slot, matching the 2× denominator in the cycle cost formula. - Time discount: The objective uses exponential discounting with
time_discount_rate^hours_aheadto match the selector's discounted score. - Export price clamping: Negative export prices and prices below
min_export_priceare clamped to 0 before solving, reflecting physical inverter behaviour. - Terminal-SoC credit: Undiscounted in the objective, matching the cost function's
terminal_soc_value.
| Parameter | Value | Rationale |
|---|---|---|
| Method | highs |
scipy's HiGHS is the only supported LP method |
| Timeout | 2.0 s | Covers 192-slot (768+ variable) problems where preprocessing reaches 200-400 ms |
pv[t] bounds |
(pv_avail[t], pv_avail[t]) |
Fixed — PV surplus is not chosen by the LP |
If scipy is unavailable, usable_kwh ≤ 0, or the solver fails (crash, timeout, or non-success status), solve_milp() returns None. The engine drops the MILP candidate and fails closed to the passive PV-only candidate; no_action remains diagnostic and cannot win. Since scipy/numpy are declared manifest.json requirements, Home Assistant installs them for every user on setup; scipy unavailable should now only occur if the environment's package installation itself failed, not as routine behavior. Plan origin is exposed as milp for a successful solve and rule_based for the defensive fallback.