This document describes the direct Python API for the optimizer packaged inside this repository.
- Package:
custom_components.wattplan.optimizer - Primary Module:
custom_components.wattplan.optimizer.mpc_power_optimizer - Primary Function:
optimize(params: OptimizationParams) -> dict
The optimizer is model-predictive-control (MPC) based.
If you are using WattPlan through the Home Assistant integration, see optimizer-profiles.md for the user-facing Aggressive, Balanced, and Conservative presets. Those profiles are integration-level presets that map onto the numeric optimizer fields documented here.
All time-indexed fields use timeslots.
- A timeslot is one fixed slice of time at your chosen resolution (for example, 15 minutes).
- Every array index corresponds to one timeslot.
- The API does not enforce a specific minutes-per-timeslot value; the caller is responsible for consistent input resolution.
The solve combines three kinds of entities:
- Battery Entities: Controllable storage with modeled charge/discharge flows and serialized policy states for inverter control.
- Comfort Entities: Postponable-but-required comfort loads, such as heating or hot water that can be shifted, but should not violate minimum comfort.
- Optional Entities: User suggestions for "might run" appliances, such as dishwashers or dryers. They are advisory only and do not affect the main optimized schedule. The output is a list of best candidate start timeslots.
from custom_components.wattplan.optimizer import OptimizationParams, optimize
params = OptimizationParams(**payload)
result = optimize(params)| Field | Type | Required | Default | Constraints | Notes |
|---|---|---|---|---|---|
grid_import_price_per_kwh |
list[float] |
Yes | - | Length 4..672, finite values |
Horizon driver (timeslot count). |
grid_export_price_per_kwh |
list[float] |
No | [] -> all zeros |
Empty or must match len(grid_import_price_per_kwh), finite values |
Per-timeslot grid export price. Zero means exported surplus has no monetary value. |
solar_input_kwh |
list[float] |
Yes* | [] |
Must match len(grid_import_price_per_kwh), finite, >= 0 |
Per-timeslot PV forecast (kWh per timeslot). |
usage_kwh |
list[float] |
Yes* | [] |
Must match len(grid_import_price_per_kwh), finite, >= 0 |
Per-timeslot base load forecast (kWh per timeslot). |
rolling_window_slots |
int |
No | 24 |
>= 1 |
Slot count used for comfort rolling-window ON accounting. |
throughput_cost_per_kwh |
float |
No | 0.0 |
Finite, >= 0 |
Extra cost on charge/discharge throughput to reduce cycling. |
action_deadband_kwh |
float |
No | 0.0 |
Finite, >= 0 |
Modeled flow commands smaller than this are treated as neutral flow. |
mode_switch_cost |
float |
No | 0.0 |
Finite, >= 0 |
Extra cost on changing battery behavior between slots. |
infer_battery_preserve_policy |
bool |
No | true |
- | Enables the model-backed counterfactual used to emit preserve battery policy states. When disabled, all battery_preserve booleans are false and non-grid-charging battery slots fall back to self_consume. |
battery_entities |
list[BatteryEntityParams] |
Yes | - | May be empty | Main controllable storage entities. |
comfort_entities |
list[ComfortEntityParams] |
Yes | - | May be empty | Required-but-shiftable comfort entities. |
optional_entities |
list[OptionalEntityParams] |
No | [] |
Fully validated for feasibility | Advisory start-time options only. |
state |
str | None |
No | None |
Valid base64 JSON object, version v=1 |
Opaque carry-over state from previous call. |
* For direct optimizer API use, solar_input_kwh and usage_kwh must still match the length of grid_import_price_per_kwh when supplied. The Home Assistant integration can synthesize or omit these sources before calling the optimizer.
Additional Global Constraints:
- Unknown fields are rejected (
extra="forbid"). - Entity names must be unique across battery + comfort + optional groups (case-insensitive).
| Field | Type | Required | Default | Constraints | Notes |
|---|---|---|---|---|---|
name |
str |
Yes | - | Non-empty | Unique globally. |
initial_kwh |
float |
Yes | - | Finite, 0..capacity_kwh |
Initial state of charge (kWh). |
target |
BatteryTargetParams | None |
No | null |
If set: timeslot < horizon |
Optional deadline target constraint. |
minimum_kwh |
float |
Yes | - | Finite, 0..capacity_kwh |
Minimum desired state (kWh). |
capacity_kwh |
float |
Yes | - | Finite, > 0 |
Storage capacity (kWh). |
charge_curve_kwh |
list[float] |
Yes | - | Non-empty, finite, >= 0 |
Chargeable energy per slot by SoC curve (kWh per slot). |
discharge_curve_kwh |
list[float] |
Yes | - | Non-empty, finite, >= 0 |
Dischargeable energy per slot by SoC curve (kWh per slot). |
charge_efficiency |
float |
No | 1.0 |
Finite, (0, 1] |
Fraction of charged energy that increases SoC. |
discharge_efficiency |
float |
No | 1.0 |
Finite, (0, 1] |
Fraction of discharged SoC energy delivered to load. |
prefer_pv_surplus_charging |
bool |
No | false |
- | Internal/deferred hint for routing PV surplus into this battery. This is not exposed as a battery action state and should not be used as a user-facing control contract. |
can_charge_from |
int |
No | 2 |
0, 1, 2, 3 |
Allowed charging-ingress flags (1=GRID, 2=PV, `3=GRID |
Curve Unit Note:
charge_curve_kwhanddischarge_curve_kwhare kWh per slot, not kW.- Example: a
2 kWunit can move at most0.5 kWhin a15-minuteslot (2 * 0.25 = 0.5). - Efficiency Semantics:
- Charging:
SoC gain = charged_energy * charge_efficiency - Discharging:
SoC drop = delivered_energy / discharge_efficiency
- Charging:
| Field | Type | Required | Default | Constraints | Notes |
|---|---|---|---|---|---|
timeslot |
int |
Yes | - | >= 0, < horizon |
Deadline timeslot, interpreted as by end of timeslot. |
soc_kwh |
float |
Yes | - | 0..capacity_kwh |
Desired SoC level at deadline (kWh). |
mode |
str |
No | "at_least" |
at_least, at_most, exact |
at_least: charge (if needed) to be at/above target by deadline. at_most: discharge (if needed) to be at/below target. exact: charge and discharge as needed to land on target (within tolerance). |
tolerance_kwh |
float |
No | 0.0 |
>= 0 |
Allowed kWh tolerance around the target level. |
| Field | Type | Required | Default | Constraints | Notes |
|---|---|---|---|---|---|
name |
str |
Yes | - | Non-empty | Unique globally. |
target_on_slots_per_rolling_window |
int |
Yes | - | >= 1, <= rolling_window_slots |
Required ON slots over rolling window. |
min_consecutive_on_slots |
int |
No | 1 |
>= 1, < solve horizon |
Minimum ON lock once enabled. |
min_consecutive_off_slots |
int |
No | 1 |
>= 1, < solve horizon |
Minimum OFF lock once disabled. |
max_consecutive_off_slots |
int |
Yes | - | >= 1, >= min_consecutive_off_slots |
Max OFF streak before force-ON. |
power_usage_kwh |
float |
Yes | - | Finite, > 0 |
Energy draw when ON (kWh per slot). |
is_on_now |
bool |
Yes | - | - | Current ON/OFF runtime state. |
on_slots_last_rolling_window |
int |
Yes | - | >= 0, <= rolling_window_slots |
Observed ON slots in prior rolling window. |
off_streak_slots_now |
int |
Yes | - | >= 0 |
Current OFF streak (slots). |
measured_power_source |
str | null |
No | null |
- | Optional source of observed power telemetry. |
recent_avg_on_power_kw |
float | null |
No | null |
Finite, > 0 |
Optional observed ON power average. |
Optional entities provide advisory start-time suggestions and do not change the optimized battery/comfort schedule.
| Field | Type | Required | Default | Constraints | Notes |
|---|---|---|---|---|---|
name |
str |
Yes | - | Non-empty | Unique globally. |
duration_timeslots |
int |
Yes | - | Duration of this optional run. | |
start_after_timeslot |
int |
No | 0 |
>= 0, < start_before_timeslot |
Earliest allowed start (inclusive). |
start_before_timeslot |
int |
Yes | - | Latest boundary (exclusive). | |
energy_kwh |
float | list[float] |
Yes | - | Finite, >= 0; list non-empty and len <= duration_timeslots |
Scalar is spread uniformly. List is spread step-wise to full duration. |
options |
int |
No | 3 |
> 0, feasible within window/gap rules |
Exact number of options returned. |
min_option_gap_timeslots |
int |
No | 0 |
>= 0 |
Minimum spacing between suggested starts. |
allow_overlapping_options |
bool |
No | false |
- | If false, effective spacing is at least duration_timeslots. |
Feasibility is validated up front. If requested options cannot fit the search window under spacing rules, validation fails.
state is an opaque base64 blob returned by one solve and accepted in the next.
- You should store and pass it back as-is.
- Do not parse or mutate it in client code.
- The optimizer may reuse overlap from prior solve data when it is compatible.
optimize(...) returns a dict with these top-level fields:
| Field | Type | Meaning |
|---|---|---|
execution_time |
float |
Solve wall time in seconds. |
generations |
int |
Number of solved timeslots (same as horizon length). |
fitness |
float |
Objective score for final schedule. |
avg_price |
float |
Average effective import price. |
projections |
dict |
Projected cost/savings metrics for this schedule. |
overconstrained |
bool |
Whether soft constraint violations were detected. |
suboptimal |
bool |
true when one or more soft targets/limits were unmet. |
suboptimal_reasons |
list[str] |
Machine-readable reason keys for suboptimal output. |
problems |
list[str] |
Human-readable issue tags (if any). |
entities |
list[dict] |
Battery/comfort schedules. |
optional_entity_options |
list[dict] |
Advisory start options per optional entity. |
state |
str |
Opaque base64 state for next call. |
Battery schedule state values are inverter-control policies derived from the plan, not raw measured or forecast battery flows:
| State | Meaning |
|---|---|
preserve |
Prevent this battery from discharging. This saves stored energy when the optimizer shows that spending it now would make the plan worse or violate modeled constraints. PV charging may still be allowed by the user's inverter setup. |
self_consume |
Normal battery operation. Allow this battery to cover real load. Do not request grid charging. This is also the default when the model has no positive reason to preserve or grid-charge. |
grid_charge |
Request or allow grid charging for this battery and prevent the battery from being spent while doing so. |
PV surplus charging is implicit/normal battery behavior, not a primary action state. PV export is site-level and multi-battery-sensitive, so a dedicated PV export policy is deferred to a future site-level design.
grid_charge is emitted when modeled grid charging for that battery is above the action deadband. preserve is emitted from a model-backed counterfactual check when infer_battery_preserve_policy is enabled: when the optimizer chooses not to discharge a battery, WattPlan asks the same model whether forcing a small discharge from that battery for marginal unexpected load would be infeasible or make the objective worse than preserving the battery and importing that marginal energy. Modeled PV surplus is consumed first in that counterfactual, so PV export is not turned into a battery action state. If the forced-discharge alternative is worse, the slot is marked preserve; otherwise WattPlan emits self_consume. Forecast zero battery flow is not a preserve reason by itself.
If infer_battery_preserve_policy is disabled, the battery_preserve boolean array is always false. In that mode, the schedule still emits grid_charge for modeled grid charging, but otherwise emits self_consume for battery slots.
entitiesis the actual optimized schedule.- Battery schedule points encode policy directly in
state:preserve,self_consume, orgrid_charge. optional_entity_optionsis advisory and computed on top of that baseline.- Optional entities do not affect each other and do not modify
entities.
baseline_cost: Baseline net energy cost across the horizon, including export revenue whengrid_export_price_per_kwhis provided.projected_cost: Projected net cost for the optimized schedule (grid imports - grid export revenue).projected_savings_cost:baseline_cost - projected_cost.projected_savings_pct:(1 - projected_cost / baseline_cost) * 100, which is equivalent to(projected_savings_cost / baseline_cost) * 100whenbaseline_cost > 0. The optimizer still emits the raw numeric result; Home Assistant sensors may choose not to expose extreme values as entity state.per_slot: List with one object per timeslot (same index/order as input arrays), each containing:baseline_costprojected_costprojected_savings_costprojected_savings_pct
Current machine-readable keys include:
battery_min_unmet: At least one battery dropped below its configuredminimum_kwhin the solved schedule.battery_target_unmet: A batterytargetconstraint (at_least/at_most/exact) was not met at its target timeslot.comfort_target_unmet: A comfort entity did not achieve its required ON slots within the rolling window.comfort_max_off_unmet: A comfort entity exceeded its configuredmax_consecutive_off_slots.
Validation happens before optimization starts.
- Invalid input raises an exception immediately (Pydantic validation error).
- Error messages identify the offending field and why it failed.
- Unknown fields are rejected.
This pre-validation/normalization design ensures the calculation phase operates on strictly shaped, trusted input.
grid import: Energy bought from the grid.grid export: Energy sent back to the grid. Also commonly calledfeed-in,export, orexport to grid.grid_import_price_per_kwh/grid_export_price_per_kwhare the optimizer terms because they are symmetric and match the physical energy flow direction.
{ // 8 timeslots (for docs brevity). Real integrations usually use more. "grid_import_price_per_kwh": [0.34, 0.31, 0.28, 0.22, 0.18, 0.21, 0.30, 0.42], "grid_export_price_per_kwh": [0.00, 0.00, 0.05, 0.08, 0.10, 0.08, 0.02, 0.00], "solar_input_kwh": [0.0, 0.1, 0.5, 1.0, 0.8, 0.3, 0.0, 0.0], "usage_kwh": [1.2, 1.1, 1.0, 0.9, 1.0, 1.2, 1.3, 1.4], "rolling_window_slots": 96, // Controllable storage: the model tracks grid/PV charge and discharge flows. "battery_entities": [ { "name": "home_battery", "initial_kwh": 4.0, "minimum_kwh": 1.0, "capacity_kwh": 10.0, "target": { "timeslot": 5, "soc_kwh": 8.0, "mode": "at_least", "tolerance_kwh": 0.1 }, "charge_curve_kwh": [2.5], "discharge_curve_kwh": [2.5], "charge_efficiency": 0.95, "discharge_efficiency": 0.95, "can_charge_from": 2 } ], // Postponable but required comfort load (must be maintained above minimum). "comfort_entities": [ { "name": "house_heat", "target_on_slots_per_rolling_window": 8, "min_consecutive_on_slots": 4, "min_consecutive_off_slots": 4, "max_consecutive_off_slots": 5, "power_usage_kwh": 1.1, "is_on_now": false, "on_slots_last_rolling_window": 3, "off_streak_slots_now": 1, "measured_power_source": null, "recent_avg_on_power_kw": null } ], // Entirely optional appliances: advisory starts only, no effect on base solve. "optional_entities": [ { "name": "dishwasher", "duration_timeslots": 2, "start_after_timeslot": 0, "start_before_timeslot": 8, "energy_kwh": 1.8, "options": 2, "min_option_gap_timeslots": 2, "allow_overlapping_options": false }, { "name": "dryer", "duration_timeslots": 4, "start_after_timeslot": 0, "start_before_timeslot": 8, "energy_kwh": [0.4, 1.2], "options": 1, "min_option_gap_timeslots": 0, "allow_overlapping_options": true } ], // Opaque state from previous optimize() response (optional). "state": null }