Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ After installing WattPlan via HACS, configure the following:
- [docs/example-deye-solcast-stromligning.md](docs/example-deye-solcast-stromligning.md) - Concrete end-to-end example using Strømligning, Deye, and Solcast
- [docs/extras.md](docs/extras.md) - Batteries, comfort loads, optional loads, and how to wire WattPlan actions into your own automations
- [docs/entities-and-services.md](docs/entities-and-services.md) - All exposed entities and services, including battery targets
- [docs/optimizer-profiles.md](docs/optimizer-profiles.md) - What Aggressive, Balanced, and Conservative mean in practice
- [docs/error-handling.md](docs/error-handling.md) - Health states, degraded operation, and what `ok`, `degraded`, and `failed` mean
- [docs/development.md](docs/development.md) - Local setup with `uv`, local test env caveats, optional symlink workflow, packaging
- [docs/architecture.md](docs/architecture.md) - Code layout, runtime boundaries, planning flow
Expand Down
6 changes: 6 additions & 0 deletions docs/optimizer-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This document describes the direct Python API for the optimizer packaged inside

The optimizer is model-predictive-control (MPC) based.

If you are using WattPlan through the Home Assistant integration, see [optimizer-profiles.md](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.

## Time Resolution (Timeslots)
All time-indexed fields use **timeslots**.
- A timeslot is one fixed slice of time at your chosen resolution (for example, 15 minutes).
Expand Down Expand Up @@ -36,6 +38,9 @@ result = optimize(params)
| `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` | Commands smaller than this are treated as hold. |
| `mode_switch_cost` | `float` | No | `0.0` | Finite, `>= 0` | Extra cost on changing battery behavior between slots. |
| `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. |
Expand All @@ -59,6 +64,7 @@ result = optimize(params)
| `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` | - | Route PV surplus into this battery instead of optimizing tiny export/recharge timing. |
| `can_charge_from` | `int` | No | `2` | `0`, `1`, `2`, `3` | Charge-source flags (`1=GRID`, `2=PV`, `3=GRID|PV`; `0` means charging disabled). |

**Curve Unit Note:**
Expand Down
70 changes: 70 additions & 0 deletions docs/optimizer-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# WattPlan Optimizer Profiles

This page describes the user-facing optimizer profiles exposed by the Home Assistant integration.

These profiles are integration presets. Internally, the optimizer still operates on numeric controls such as throughput cost, action deadband, and mode-switch cost. The integration translates the selected profile into those numeric values before calling the optimizer.

## When to use each profile

### Aggressive

Use this when savings are the main goal and you are comfortable with the battery moving more often.

Typical behavior:
- Takes more charging and discharging opportunities when they look economically useful
- Is more willing to make smaller battery moves
- Can produce more active battery schedules

This is usually a good fit when:
- You want WattPlan to chase price differences more actively
- Battery wear is a lower concern than short-term economics
- You prefer the battery to work harder when there is value in doing so

### Balanced

This is the default and should fit most homes.

Typical behavior:
- Still pursues useful savings opportunities
- Avoids some of the smaller or twitchier battery moves
- Keeps behavior calmer without making the battery overly passive

This is usually a good fit when:
- You want a practical middle ground
- You care about savings and battery wear
- You want stable behavior without giving up the main value of planning

### Conservative

Use this when you want the battery to behave more steadily and avoid marginal moves.

Typical behavior:
- Ignores more small or borderline battery actions
- Produces calmer plans with less switching
- Gives up some savings in exchange for less battery activity

This is usually a good fit when:
- Battery wear matters more than small extra savings
- You dislike frequent small charge/discharge changes
- You want simpler, quieter battery behavior

## What profiles do not do

Profiles do not raise the configured battery minimum.

If you want more reserve left in a battery, set that battery's minimum energy directly in the battery configuration. Profiles only control how willing WattPlan is to move battery energy around.

Profiles also do not replace battery targets. If you need a battery, such as an EV, to reach a specific level by a specific time, use a target. A common Home Assistant setup is an automation that sets a weekday morning target and adjusts it for holidays or other patterns.

## PV surplus charging on individual batteries

Each battery also has a separate `Prefer PV surplus charging` option.

This is most useful for:
- EV batteries
- Charge-only batteries
- Batteries that should generally trend toward being charged when solar surplus is available

When enabled, WattPlan treats that battery as a sink for available PV surplus instead of trying to optimize small export-now / charge-later timing differences.

This option is local to that battery. It does not change the global optimizer profile.
111 changes: 99 additions & 12 deletions src/custom_components/wattplan/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
CONF_ON_OFF_SOURCE,
CONF_OPTIONS_COUNT,
CONF_PLANNING_ENABLED,
CONF_OPTIMIZER_PROFILE,
CONF_PREFER_PV_SURPLUS_CHARGING,
CONF_PROVIDERS,
CONF_RESAMPLE_MODE,
CONF_ROLLING_WINDOW_HOURS,
Expand All @@ -87,6 +89,9 @@
FIXUP_PROFILE_REPAIR,
FIXUP_PROFILE_STRICT,
HOURS_TO_PLAN_OPTIONS,
OPTIMIZER_PROFILE_AGGRESSIVE,
OPTIMIZER_PROFILE_BALANCED,
OPTIMIZER_PROFILE_CONSERVATIVE,
RESAMPLE_MODE_FORWARD_FILL,
RESAMPLE_MODE_LINEAR,
RESAMPLE_MODE_NONE,
Expand Down Expand Up @@ -1134,14 +1139,55 @@ async def _async_config_translation(
return message


def _optimizer_profile_selector(default: str) -> selector.SelectSelector:
"""Build selector for user-facing optimizer profiles."""
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=OPTIMIZER_PROFILE_AGGRESSIVE,
label="Aggressive",
),
selector.SelectOptionDict(
value=OPTIMIZER_PROFILE_BALANCED,
label="Balanced",
),
selector.SelectOptionDict(
value=OPTIMIZER_PROFILE_CONSERVATIVE,
label="Conservative",
),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
)


def _core_schema(
defaults: dict[str, Any] | None = None, *, include_name: bool = False
defaults: dict[str, Any] | None = None,
*,
include_name: bool = False,
include_profile: bool = False,
profile_last: bool = False,
) -> vol.Schema:
"""Build schema for the core planner settings."""
defaults = defaults or {}
slot_default = str(defaults.get(CONF_SLOT_MINUTES, 15))
hours_default = str(defaults.get(CONF_HOURS_TO_PLAN, 48))
schema: dict[Any, Any] = {
schema: dict[Any, Any] = {}
profile_field = None
if include_profile:
profile_field = (
vol.Required(
CONF_OPTIMIZER_PROFILE,
default=str(
defaults.get(CONF_OPTIMIZER_PROFILE, OPTIMIZER_PROFILE_BALANCED)
),
),
_optimizer_profile_selector(
str(defaults.get(CONF_OPTIMIZER_PROFILE, OPTIMIZER_PROFILE_BALANCED))
),
)
schema.update({
vol.Required(CONF_SLOT_MINUTES, default=slot_default): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[str(option) for option in SLOT_MINUTE_OPTIONS],
Expand All @@ -1154,11 +1200,17 @@ def _core_schema(
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
}
})
if include_profile and not profile_last:
assert profile_field is not None
schema[profile_field[0]] = profile_field[1]
if include_name:
schema[vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, "WattPlan"))] = (
selector.TextSelector()
)
if include_profile and profile_last:
assert profile_field is not None
schema[profile_field[0]] = profile_field[1]
return vol.Schema(schema)


Expand Down Expand Up @@ -1455,6 +1507,7 @@ def _battery_schema() -> vol.Schema:
default={
CONF_CHARGE_EFFICIENCY: 0.9,
CONF_DISCHARGE_EFFICIENCY: 0.9,
CONF_PREFER_PV_SURPLUS_CHARGING: False,
},
): section(
vol.Schema(
Expand All @@ -1481,6 +1534,10 @@ def _battery_schema() -> vol.Schema:
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Required(
CONF_PREFER_PV_SURPLUS_CHARGING,
default=False,
): selector.BooleanSelector(),
}
),
{"collapsed": True},
Expand Down Expand Up @@ -1871,6 +1928,7 @@ class WattPlanConfigFlow(ConfigFlow, domain=DOMAIN):
MINOR_VERSION = 1

_core: dict[str, Any]
_entry_options: dict[str, Any]
_sources: dict[str, dict[str, Any]]
_last_source_available_count: int | None = None
_pending_source_key: str | None = None
Expand Down Expand Up @@ -1924,14 +1982,24 @@ async def async_step_planner_setup(
if user_input is not None:
errors = _validate_core_data(user_input, include_name=True)
if not errors:
self._core = _normalize_core_input(user_input)
normalized = _normalize_core_input(user_input)
self._entry_options = {
CONF_PLANNING_ENABLED: True,
CONF_ACTION_EMISSION_ENABLED: True,
CONF_OPTIMIZER_PROFILE: str(
normalized.pop(
CONF_OPTIMIZER_PROFILE, OPTIMIZER_PROFILE_BALANCED
)
),
}
self._core = normalized
self._sources = {}
return await self.async_step_source_price()

return self.async_show_form(
step_id="planner_setup",
data_schema=self.add_suggested_values_to_schema(
_core_schema(include_name=True), user_input or {}
_core_schema(include_name=True, include_profile=True), user_input or {}
),
errors=errors,
last_step=False,
Expand Down Expand Up @@ -2578,10 +2646,7 @@ async def async_step_setup_complete(
**{key: value for key, value in self._core.items() if key != CONF_NAME},
CONF_SOURCES: self._sources,
},
options={
CONF_PLANNING_ENABLED: True,
CONF_ACTION_EMISSION_ENABLED: True,
},
options=self._entry_options,
)

return self.async_show_form(
Expand Down Expand Up @@ -2658,6 +2723,9 @@ def __init__(self, config_entry: ConfigEntry) -> None:
self._options = deepcopy(dict(config_entry.options))
self._options.setdefault(CONF_PLANNING_ENABLED, True)
self._options.setdefault(CONF_ACTION_EMISSION_ENABLED, True)
self._options.setdefault(
CONF_OPTIMIZER_PROFILE, OPTIMIZER_PROFILE_BALANCED
)
self._selected_subentry_id = None
self._last_source_available_count = None
self._pending_source_key = None
Expand All @@ -2672,12 +2740,12 @@ async def async_step_init(
"""Menu for options."""
menu_options = [
"planner_core",
"planner_timers",
"source_price",
"source_usage",
"source_pv",
"source_export_price",
]
menu_options.append("planner_timers")

return self.async_show_menu(
step_id="init",
Expand All @@ -2692,14 +2760,29 @@ async def async_step_planner_core(
if user_input is not None:
errors = _validate_core_data(user_input)
if not errors:
self._data.update(_normalize_core_input(user_input))
normalized = _normalize_core_input(user_input)
self._options[CONF_OPTIMIZER_PROFILE] = str(
normalized.pop(CONF_OPTIMIZER_PROFILE, OPTIMIZER_PROFILE_BALANCED)
)
self._data.update(normalized)
self.hass.config_entries.async_update_entry(self.config_entry, data=self._data)
self.hass.config_entries.async_update_entry(
self.config_entry, options=self._options
)
return await self.async_step_init()

return self.async_show_form(
step_id="planner_core",
data_schema=self.add_suggested_values_to_schema(
_core_schema(self._data), user_input or {}
_core_schema(
{
**self._data,
CONF_OPTIMIZER_PROFILE: self._options[CONF_OPTIMIZER_PROFILE],
},
include_profile=True,
profile_last=True,
),
user_input or {},
),
errors=errors,
)
Expand Down Expand Up @@ -3710,6 +3793,7 @@ def _normalize_battery_input(user_input: dict[str, Any]) -> dict[str, Any]:
data.update(data.pop(SECTION_BATTERY_ADVANCED, {}))
data.setdefault(CONF_CHARGE_EFFICIENCY, 0.9)
data.setdefault(CONF_DISCHARGE_EFFICIENCY, 0.9)
data.setdefault(CONF_PREFER_PV_SURPLUS_CHARGING, False)
return data


Expand All @@ -3719,6 +3803,9 @@ def _battery_form_defaults(data: dict[str, Any]) -> dict[str, Any]:
defaults[SECTION_BATTERY_ADVANCED] = {
CONF_CHARGE_EFFICIENCY: defaults.get(CONF_CHARGE_EFFICIENCY, 0.9),
CONF_DISCHARGE_EFFICIENCY: defaults.get(CONF_DISCHARGE_EFFICIENCY, 0.9),
CONF_PREFER_PV_SURPLUS_CHARGING: defaults.get(
CONF_PREFER_PV_SURPLUS_CHARGING, False
),
}
return defaults

Expand Down
6 changes: 6 additions & 0 deletions src/custom_components/wattplan/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
CONF_CAN_CHARGE_FROM_PV = "can_charge_from_pv"
CONF_CAPACITY_KWH = "capacity_kwh"
CONF_CHARGE_EFFICIENCY = "charge_efficiency"
CONF_OPTIMIZER_PROFILE = "optimizer_profile"
CONF_PREFER_PV_SURPLUS_CHARGING = "prefer_pv_surplus_charging"
CONF_COMFORTS = "comforts"
CONF_DURATION_MINUTES = "duration_minutes"
CONF_DISCHARGE_EFFICIENCY = "discharge_efficiency"
Expand Down Expand Up @@ -104,6 +106,10 @@
ENERGY_MODE_SCALAR = "scalar"
ENERGY_MODE_PROFILE = "profile"

OPTIMIZER_PROFILE_AGGRESSIVE = "aggressive"
OPTIMIZER_PROFILE_BALANCED = "balanced"
OPTIMIZER_PROFILE_CONSERVATIVE = "conservative"

SLOT_MINUTE_OPTIONS: tuple[int, ...] = (15, 30, 60)
HOURS_TO_PLAN_OPTIONS: tuple[int, ...] = (12, 24, 48, 72, 96, 120, 144, 168)

Expand Down
Loading
Loading