This document describes the direct Python API for the optimizer packaged inside this repository.
Interchangeable planners use this API through built-in names or a
module:factory adapter. See Planner factories for the
extension interface and Planner simulator for causal execution.
Home Assistant selects a finite named configuration through
optimizer.planners.presets.run_preset(params, name). Its default configuration
is Local search (local-search-3), presented as Recommended — Local search
in initial Home Assistant setup. The other normal choices are Charge-window
search, Guarded value policy, Joint seed search, and Self-consumption;
see Planner configurations for
their IDs and behavior. Existing Home Assistant entries keep their stored
selection; entries that relied on the old implicit default are migrated to an
explicit highs selection. The highs configuration fixes the legacy 22-slot
lookahead; highs-8, highs-16, and highs-24
select fixed lookahead slots. Every named configuration fixes algorithm tuning,
including zero throughput, deadband, and mode-switch penalties, a 20-second
HiGHS solve limit, and preserve-policy inference. These settings are applied
after request validation and do not change the supplied equipment, forecasts,
comfort requirements, targets, slot duration, horizon, or carried state.
Direct optimize(params) callers continue to select HiGHS and use the tuning
fields supplied in params. See planner configurations.
The detailed solver semantics below describe HiGHS unless stated otherwise.
The policy-search alternatives share the input model and live result fields,
but use full-horizon discrete battery actions, target-first scoring, and their
own search budgets. The shared wrapper handles comfort and optional appliances; see
shared features and limits
for scheduling order and reporting semantics.
Algorithm-specific diagnostics live under diagnostics; HiGHS solve/cadence
counters are absent from planners that do not perform those operations.
- 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.
Import and export tariffs retain their supplied signed values in the battery solve. Negative import prices can therefore make feasible grid charging economically beneficial. Comfort timing starts from a constraint-driven schedule and may be improved by bounded whole-schedule placement outside the battery MILP. Direction constraints prevent a slot from importing and exporting simultaneously, prevent a battery from charging and discharging simultaneously, limit grid-sourced charging to actual grid import, and reserve PV charging/export for physical PV surplus after household and scheduled comfort demand.
The model does not export battery energy, add an export-first battery mode, curtail forecast PV, or assign a terminal value to energy remaining beyond the supplied horizon. A grid_charge schedule state is an on/off inverter policy, not a throttled power setpoint: an enabled slot uses the configured full charging rate, with PV supplying available surplus first and the battery's remaining capacity limiting the final input. The optimizer prices this executable charge before choosing the policy. A capacity-limited top-up is only considered when at least half the configured slot energy can fit (with a small numeric feasibility tolerance); smaller top-ups are OFF. Charging may stop early at capacity without commanding a partial rate.
The continuous model can still prefer exporting PV over storing it when stored energy has no modeled future value, even though an empty PV-capable battery receiving the coarse self_consume policy would normally charge from surplus first. PV charging under self_consume is ordinary inverter behavior, not a separate controllable action in this policy model.
If you are using WattPlan through Home Assistant, the selected planner configuration owns
algorithm tuning. Earlier profile and lookahead options saved in a config entry
are ignored; hours_to_plan still controls the forecast horizon. The request
model fields below remain available to direct optimize(params) callers and
custom planner experiments.
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.
lookahead_slotsis also expressed in this same resolution. Named configurations select a fixed slot count; directoptimize(params)callers supply it in the request.
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: Required comfort loads, such as heating or hot water. A deterministic feasible baseline is followed by bounded cost-aware relocation of existing ON runs.
- 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. |
lookahead_slots |
int |
No | 22 |
2..672 |
Maximum future slots considered when selecting each current action. Direct API callers that omit it retain the historical 22-slot behavior. The full supplied horizon is still returned. |
throughput_cost_per_kwh |
float |
No | 0.0 |
Finite, >= 0 |
Heuristic objective weight per kWh of charge/discharge throughput. It discourages cycling but is not a monetary battery-wear estimate. |
action_deadband_kwh |
float |
No | 0.0 |
Finite, >= 0 |
Battery flow is constrained to zero or at least this inclusive threshold. |
mode_switch_cost |
float |
No | 0.0 |
Finite, >= 0 |
Heuristic objective weight that discourages changing modeled battery behavior. It is not a monetary switching or wear estimate. |
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. |
solver_time_limit_seconds |
float |
No | 20 |
> 0, <= 3600 |
Wall-clock limit for each MILP solve. A solve stopped by the limit is used when HiGHS holds a feasible solution (logged as a warning) and fails otherwise. The integration additionally bounds the complete worker invocation at 180 seconds, kills and reaps timed-out work, discards the plan and its disk cache, and reports a planner_timeout error with guidance to pick another planner. |
battery_entities |
list[BatteryEntityParams] |
Yes | - | May be empty | Main controllable storage entities. |
comfort_entities |
list[ComfortEntityParams] |
Yes | - | May be empty | Required comfort entities with bounded cost-aware placement. |
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. |
plan_start |
timezone-aware datetime or ISO datetime string |
No | None |
Must include a timezone; normalized to UTC | Start of forecast slot zero. Enables clock-aligned prefix refresh. The integration supplies its aligned source-window start. |
slot_minutes |
int |
No | 15 |
1..1440 |
Slot duration used to align timed requests and carry commitments. |
solar_input_kwh and usage_kwh are required in practice: the model defaults them to empty lists, but validation rejects any length other than len(grid_import_price_per_kwh), so omitting them fails validation. Direct callers pass an all-zero list to model "no PV" or "no base load". The Home Assistant integration synthesizes these series from its configured 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).
- Comfort minimum ON/OFF durations must each be shorter than
min(lookahead_slots, horizon).
| 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 |
BatteryEntityParams.TargetParams | None |
No | null |
If set: timeslot < horizon |
Optional deadline target constraint. |
minimum_kwh |
float |
Yes | - | Finite, 0..capacity_kwh |
Minimum desired state (kWh). Soft floor: search penalizes, rather than forbids, ending a slot below it. Planners declaring reserve recovery pursue recovery within their limits; passive controls only observe it. The plan reports battery_min_unmet for as long as the planned SoC stays below the floor. |
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=both; 0 means charging disabled). |
connected_slots |
list[bool] | null |
No | null |
Strict booleans, exactly horizon length | false disables all site AC exchange for this battery in that slot. Null means always connected. |
external_energy_kwh |
list[float] | null |
No | null |
Finite nonnegative DC energy, exactly horizon length | Withdrawal from this battery at the end of each slot, after household exchange and departure checks; null means zero. |
departure_requirements |
list[DepartureRequirementParams] |
No | [] |
Each timeslot within horizon and minimum_kwh within capacity |
Repeated minimum-energy checks before that slot's external withdrawal. |
DepartureRequirementParams has timeslot (nonnegative slot index) and
minimum_kwh (finite nonnegative DC stored energy, at most battery capacity).
It is separate from the ordinary end-slot battery target: departures are
checked after that slot's permitted site exchange but before its external
withdrawal; an ordinary target checks the post-withdrawal level. External draws
can consume energy below the household reserve, down to zero. Excess draw is
reported as unserved energy and never manufactured. An away battery cannot
charge, absorb PV or discharge into household demand; its external withdrawal
still occurs.
Active mobility metadata requires an explicit planner capability
PlannerCapabilities(mobility=True). The new mobility-baseline,
mobility-tariff-seeds, mobility-hybrid-seeds and mobility-fleet-beam factories
provide it. Existing families reject active metadata before planning; all-true connection lists,
zero external draws and no departure requirements are inert. The ordinary
default request, score and result fields retain their previous behavior.
Capability targets="observed" distinguishes the passive household control in
the baseline and two-seed mobility planners from ordinary target search.
economic_scope is household (household economics, with optional mobility),
mobility (only mobile charging economics), or none. reserve_recovery states
whether deliberate initial-below-reserve recovery is pursued across the
supported fleet. Neither field promises feasibility or optimality. Mobility
baseline declares none/false; tariff seeds mobility/false; fleet beam declares
household/true with mobility enabled. Existing self-consumption declares
none/false. Merely observing reserve and target misses does not imply an
algorithm attempts to fix them.
mobility-hybrid-seeds also declares household economics, reserve recovery and
optimized targets. Its four complete seeds combine metadata-free raw stationary
hybrid-policy (default grid 4, no scenario branches) or passive control with two
mobile charging controls, using joint shared physics and full service-first
scores. Ordinary mobile targets use a disclosed whole-fleet bounded beam fallback
(width 4, 1500 expansions). These declarations describe search intent, not an
optimality or feasibility guarantee; input-order PV allocation is unchanged.
Shared physics.transition_at(batteries, levels, modes, *, slot, usage, pv, slot_fraction=1.0, apply_external=True, grid_eligible=None) applies availability
and then the end-slot withdrawal. Fractional execution applies the external draw
once at the original slot boundary, not once per fractional substep. Its optional
levels_before_external, external_energy (served DC) and external_shortfall
lists describe that boundary accounting; final levels are post-withdrawal.
For active requests shared Evaluation.score is
(mobility_miss_kwh, target_miss_kwh, objective), where mobility miss adds
departure shortfalls and unserved DC energy. Without active mobility it retains
the existing two-component score. Full-horizon original-input reporting adds
projections.mobility_served_kwh, mobility_unserved_kwh, departure_checks
and service_feasible; per-slot flows add external_energy_kwh and
external_unserved_kwh. Served trips are not site AC import, inverter throughput
or electricity tariff expense. Infeasible forecast service remains visible.
The conventional household-without-storage baseline_cost excludes vehicle
service. Active projections therefore disclose
baseline_kind="household_without_storage_excludes_mobility_service" and
projected_savings_comparable=False; its generic savings field is not an EV
service comparison. Compare electricity cost against a mobility-capable baseline
only when departure service and unserved draws are equivalent, with terminal
stored energy also visible.
Completed simulator runs containing active mobility report battery_flows on
every actual interval, including intervals whose current forecast has no mobility
events. Entries follow request battery order and contain name, AC
charge_kwh (PV plus grid), AC discharge_kwh, level_before_kwh,
level_before_external_kwh and final level_kwh. The interval's
external_energy_by_battery contains served DC withdrawals. These fields allow
independent per-battery conservation, availability and power-limit checks;
external DC draws are excluded from site AC balance and conversion loss.
Simulator totals disclose unserved energy, departure misses and
service_feasible. Incomplete or service-infeasible runs cannot enter savings
comparisons; diagnostic costs remain visible. A service-infeasible reference
suppresses comparisons rather than substituting a different baseline. Runs with
only inert metadata retain the legacy report shape.
See mobility configurations for search budgets, baseline behavior and limitations. These additions provide optimizer and simulator API support, not a Home Assistant vehicle-source configuration.
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:
- Known limitations of the power curves and targets:
- Each MPC solve reads the charge and discharge limits once, at the battery's SoC when that solve starts, and applies them to every slot in its lookahead. A curve that tapers with SoC is therefore exact for the slot being decided but only an approximation for later slots in the window (a battery that will charge into the taper is planned as if it kept its current rate). Each following solve starts from the real SoC and corrects this, and applied controls are always clipped to the true curve.
- A
targetbecomes a constraint only once its deadline falls inside the lookahead window. A target further away is invisible to the solve until it enters the window, and by then a large or slow-to-reach SoC may no longer be reachable; the plan then reportsbattery_target_unmet. Use alookahead_slotslarge enough to cover the time needed to reach the target, or set the target earlier. - Energy left in a battery at the end of the lookahead (or the horizon) has no terminal value, so the plan does not hold charge for value beyond what it can see.
| 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 |
No | 0 |
>= 0, <= rolling_window_slots |
Deprecated aggregate retained for compatibility when ordered history is unavailable. |
on_history |
list[bool] | null |
No | null |
Exactly rolling_window_slots - 1 values when present |
Ordered observed states, oldest first, immediately before forecast slot 0. Used by the deterministic scheduler to enforce rolling demand and infer an unfinished ON run. |
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. |
With ordered history, a trailing OFF observation followed by is_on_now=true
is treated as a newly started ON run. The planner keeps that run ON for the
configured minimum duration. While currently OFF, off_streak_slots_now supplies
the completed OFF duration and determines the remaining minimum-OFF commitment.
If ordered ON history is unavailable, the age of a currently ON run cannot be
inferred, so the planner does not restart its minimum-ON timer on each refresh.
Optional entities provide advisory start-time suggestions and do not change the published battery/comfort schedule. Each candidate is valued by replaying the supplied forecast horizon from the original battery state under the unchanged published battery modes and comfort schedule. The replay applies the candidate load to physical PV, battery, import, and export flows, including later tariff cost caused by changed battery state, and compares it with the same fixed-policy replay without the load. Signed import and export tariffs are used in both replays. Heuristic throughput and mode-switch weights are not added to replay costs or used to rank optional-load starts.
| Field | Type | Required | Default | Constraints | Notes |
|---|---|---|---|---|---|
name |
str |
Yes | - | Non-empty | Unique globally. |
duration_timeslots |
int |
Yes | - | > 0, <= horizon |
Duration of this optional run. |
start_after_timeslot |
int |
No | 0 |
>= 0, < start_before_timeslot |
Earliest allowed start (inclusive). |
start_before_timeslot |
int |
Yes | - | >= 1, > start_after_timeslot |
Latest boundary (exclusive). The latest start is start_before_timeslot - duration_timeslots. |
energy_kwh |
float | list[float] |
Yes | - | Finite, >= 0; list non-empty and len <= duration_timeslots |
A scalar is the total energy of the run, spread uniformly over duration_timeslots. A list is a per-slot energy profile, not a total: if shorter than the duration it is stretched step-wise (each entry covers a proportional block of slots and keeps its per-slot value), so the run's total energy is not preserved. For example [0.4, 1.2] over four slots draws 0.4, 0.4, 1.2, 1.2 kWh (3.2 kWh in all). |
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. Internally, trajectory data that can be recomputed (the per-slot comfort history) is stored only as its starting row and rebuilt on decode, which keeps the blob small; blobs from earlier versions that stored the full history still decode.
- With
plan_start, the optimizer normally computes a full plan, then refreshes the first eight battery actions on each of the next three consecutive slot advances. The fourth advance computes a new full plan. At 15-minute resolution this is full at 00:00, prefix refresh at 00:15/00:30/00:45, and full at 01:00. The configured economic lookahead is unchanged for every fresh decision; an eight-slot refresh does not mean an eight-slot lookahead. - Every response still covers the complete forecast. The reused battery tail is applied to current forecast load/PV and newly calculated SOC. Comfort is regenerated from current rolling history and minimum-on/off commitments, then bounded placement may improve its timing before it is fixed as demand. Battery policies are retained even when physical limits produce zero flow.
- A same-slot refresh uses a zero shift and does not advance the cadence clock. Skipped, reversed, unaligned, or differently sized windows, changed resolution/configuration, and incompatible state cause a full plan. Optional recommendation changes do not invalidate the battery/comfort cadence. Target compatibility uses the absolute end-of-slot deadline.
- First-slot comfort commitments use expiry times on the planning timeline, checked against observed modes and compatible durations. Ordered observed history can also establish an ongoing commitment, including after a skipped refresh. Predicted future transitions are not assumed to have been actuated.
- The deterministic comfort scheduler recalculates the entire baseline comfort schedule on every call. Bounded placement preserves runtime, rolling windows, minimum runs, maximum OFF time, and current locks. Invalid baselines are reported explicitly and are neither priced nor moved. If placement changes demand, battery control arrays are not reused; at most one additional battery planning pass is allowed. A rebuilt plan is accepted only when actual tariff cost does not worsen and hard constraints remain valid.
- Saved state from earlier comfort scheduling implementations is invalidated for control reuse. The first request after the bounded-placement scheduler change fully replans, while retaining compatible observed/first-slot comfort commitments.
- Short horizons (nine slots or fewer) are fully planned. Requests without
plan_startretain the legacy full-calculation/reuse path; the optimizer does not infer a new tick from call count. A timed state handed to an untimed request is fully replanned. Older opaque state remains accepted but cannot enable clock-based partial refresh.
Prefix refresh changes how often future battery decisions are optimized, not the forecast resolution. Comfort placement is bounded on full and prefix calls and may shift feasible existing runs toward lower whole-site tariff cost. Optional recommendations are recalculated against the final accepted comfort schedule and published battery policies; they are not a guarantee of equivalence to a newly optimized full policy horizon. Full calls and native solver outliers are still possible; the cadence is not a hard execution-time limit.
optimize(...) returns a dict with these top-level fields:
| Field | Type | Meaning |
|---|---|---|
execution_time |
float |
Solve wall time in seconds. |
fitness |
float |
Internal objective score of the final schedule: net tariff cost plus constraint-violation and switching penalties. Lower is better; intended for diagnostics and comparison between plans of the same request, not as a monetary figure. |
avg_price |
float |
Net tariff cost of the schedule (import cost minus export revenue) divided by grid-imported kWh. Export revenue lowers it, so it can be negative. A sentinel of 1000.0 is returned when the schedule imports no energy. |
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. |
cadence |
dict, timed requests only |
Full/repair/fallback mode, reason, phase, optimized/replayed battery-slot counts, and provisional tail count. |
comfort_placement |
dict, comfort requests only |
Bounded search status, cost mode, work counts, explicit violations, demand-change/replan accounting, actual baseline/final projected cost, and fallback reason. |
successful_solves |
int |
Primary MPC solves performed in this call, including a baseline and at most one changed-demand rebuild; preserve probes are additional. No-battery plans use direct flow accounting and report zero. |
reused_steps |
int |
Old battery schedule positions considered for reuse. Comfort is generated independently. |
energy_flows |
dict |
Accepted physical energy accounting, with one entry per input slot. |
per_slotfollows input order and uses kWh per slot from the final accepted schedule, not a separate reconstruction or solve.- Each item contains
load_kwh(base load, scheduled comfort load and accepted battery charge/discharge),base_load_kwh,comfort_load_kwh,grid_import_kwh,grid_export_kwh, andgrid_charge_kwh. - Grid import/export are mutually exclusive in this accounting. Grid charging is
included in both
load_kwhand grid import when it cannot be met by PV.
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 full feasible slot-rate charging for this battery and prevent the battery from being spent while doing so. PV surplus can provide part of the charge; the inverter stops early if capacity is reached. |
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 selected as an on/off decision in the MILP: charging while ON must use the full feasible slot rate, with capacity saturation and available PV reducing the grid contribution. A capacity-limited slot shorter than half the configured full slot is rejected, rather than relying on exact SoC equality near full. The action deadband prevents an otherwise PV-supplied ON slot from publishing a meaningless grid-charge request; it does not throttle charging. 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.
The returned battery states remain policies rather than exact energy setpoints. A full-rate grid-charge choice is costed before publication, but real inverter limits, forecast error, and self_consume/preserve behavior can still differ from the model. WattPlan does not apply a blanket nonnegative-savings veto: mandatory battery targets and comfort requirements can legitimately cost more than the no-action baseline, and comparing policies safely requires an equivalent feasible reference with an explicit terminal-energy convention.
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 published schedule. On timed requests, battery points havefreshnessvaluesoptimized,reprojected, orprovisional; comfort points arescheduledbecause their entire deterministic schedule is recalculated each time.- 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. - Each option's
incremental_costis its fixed-policy tariff replay cost minus the no-added-load fixed-policy tariff replay cost under the same signed tariffs. The replay can change hypothetical battery energy flows and later SoC, but it does not reoptimize or mutate the returned schedule or opaque state. Heuristic throughput and mode-switch weights are excluded. - Alternatives are independent suggestions. Their costs do not assume that multiple options or optional loads run together.
- Ranking is optimal only among the configured candidate starts under the published fixed modes and supplied forecast horizon. No terminal battery value beyond that horizon is invented.
- All projection cost and savings fields are tariff-only monetary estimates. They use supplied signed import/export tariffs and exclude the heuristic
throughput_cost_per_kwhandmode_switch_costobjective weights. They therefore do not estimate battery degradation, switching wear, or total ownership cost. baseline_cost: Baseline net tariff cost across the horizon, including export revenue whengrid_export_price_per_kwhis provided.projected_cost: Projected net tariff 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 ends a slot below its configuredminimum_kwhin the solved schedule. The minimum is a soft floor, so this is reported, not a solve failure, when a battery starts below its minimum and cannot recover within the first slot(s), or when other constraints force it under.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_history_unavailable: Ordered pre-forecast history was unavailable. The optimizer used only aggregate credit guaranteed for every possible ordering, so it does not claim history-dependent comfort validity.comfort_max_off_unmet: A comfort entity exceeded its configuredmax_consecutive_off_slots.comfort_min_run_unmet: An inherited comfort lock could not be honored, or a forecast transition could not complete its configured minimum ON/OFF run within the horizon.
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.
optimizer.planners.create_planner(name, options) accepts a built-in algorithm
name or an importable module:factory. The factory receives keyword options and
returns a callable taking OptimizationParams. It validates options and declares
PlannerCapabilities. See the planner modules' opening docstrings for algorithm
details; the user guide describes available configurations.
Battery implementations return a complete schedule for every named battery in
request order, with finite projected levels. Shared policy actions are
self_consume, preserve, and grid_charge, subject to each battery's permitted
energy sources. planners.evaluation.evaluate_schedule supplies physical replay;
build_result can reuse that evaluation for projections and reporting.
planners.shared.wrap_battery_planner adds comfort scheduling and continuity,
optional-appliance recommendations, and whole-call timing. The battery search
receives comfort consumption as part of its demand forecast.
The policy evaluator ranks target misses before its economic score, which includes reserve shortfall and cycling/switching preferences. Published costs contain only tariffs. This score is not comparable with HiGHS's internal objective. Search budgets must count evaluated candidates; algorithms using a shorter search horizon must still return complete schedules and disclose ignored preferences. Registry-wide tests cover the shared contract; see test organization for coverage and planner-quality evaluation.
The registry namespaces returned state by implementation, options, and state
version. A mismatch resets it. Stateless battery searches return None, though
the comfort wrapper can still retain runtime commitments. Cached plans must be
checked against current forecasts, equipment, targets, and time before reuse.
{ // 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": 8, "lookahead_slots": 48, // 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, "on_history": [false, false, true, true, true, false, false], "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 }