diff --git a/README.md b/README.md index 317a1cf..9516133 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/optimizer-api.md b/docs/optimizer-api.md index d958dd0..3ade86d 100644 --- a/docs/optimizer-api.md +++ b/docs/optimizer-api.md @@ -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). @@ -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. | @@ -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:** diff --git a/docs/optimizer-profiles.md b/docs/optimizer-profiles.md new file mode 100644 index 0000000..cacc27a --- /dev/null +++ b/docs/optimizer-profiles.md @@ -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. diff --git a/src/custom_components/wattplan/config_flow.py b/src/custom_components/wattplan/config_flow.py index 7ab61ea..14abf00 100644 --- a/src/custom_components/wattplan/config_flow.py +++ b/src/custom_components/wattplan/config_flow.py @@ -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, @@ -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, @@ -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], @@ -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) @@ -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( @@ -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}, @@ -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 @@ -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, @@ -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( @@ -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 @@ -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", @@ -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, ) @@ -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 @@ -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 diff --git a/src/custom_components/wattplan/const.py b/src/custom_components/wattplan/const.py index 48b87f6..10f6091 100644 --- a/src/custom_components/wattplan/const.py +++ b/src/custom_components/wattplan/const.py @@ -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" @@ -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) diff --git a/src/custom_components/wattplan/coordinator.py b/src/custom_components/wattplan/coordinator.py index c2fd197..7656291 100644 --- a/src/custom_components/wattplan/coordinator.py +++ b/src/custom_components/wattplan/coordinator.py @@ -45,6 +45,7 @@ CONF_MIN_CONSECUTIVE_ON_MINUTES, CONF_MIN_OPTION_GAP_MINUTES, CONF_MINIMUM_KWH, + CONF_OPTIMIZER_PROFILE, CONF_ON_OFF_SOURCE, CONF_OPTIONS_COUNT, CONF_ROLLING_WINDOW_HOURS, @@ -58,6 +59,10 @@ CONF_SOURCE_USAGE, CONF_SOURCES, CONF_TARGET_ON_HOURS_PER_WINDOW, + CONF_PREFER_PV_SURPLUS_CHARGING, + OPTIMIZER_PROFILE_AGGRESSIVE, + OPTIMIZER_PROFILE_BALANCED, + OPTIMIZER_PROFILE_CONSERVATIVE, DOMAIN, SOURCE_MODE_BUILT_IN, SOURCE_MODE_NOT_USED, @@ -84,6 +89,23 @@ _LOGGER = logging.getLogger(__name__) HEARTBEAT_OFFSET = timedelta(minutes=3) STORAGE_VERSION = 1 +PROFILE_SETTINGS = { + "aggressive": { + "throughput_cost_per_kwh": 0.0, + "action_deadband_kwh": 0.0, + "mode_switch_cost": 0.0, + }, + "balanced": { + "throughput_cost_per_kwh": 0.02, + "action_deadband_kwh": 0.05, + "mode_switch_cost": 0.01, + }, + "conservative": { + "throughput_cost_per_kwh": 0.08, + "action_deadband_kwh": 0.1, + "mode_switch_cost": 0.03, + }, +} def _snapshot_schema_id() -> str: @@ -734,6 +756,9 @@ async def _async_build_planning_request(self, entry: ConfigEntry) -> dict[str, A ) ], "can_charge_from": can_charge_from, + "prefer_pv_surplus_charging": bool( + subentry.data.get(CONF_PREFER_PV_SURPLUS_CHARGING, False) + ), } if target := get_active_battery_target(runtime_data, subentry_id): target_slot = self._target_timeslot_from_timestamp( @@ -894,6 +919,14 @@ async def _async_build_planning_request(self, entry: ConfigEntry) -> dict[str, A "rolling_window_slots": ( next(iter(rolling_window_slots_set)) if rolling_window_slots_set else 24 ), + **PROFILE_SETTINGS.get( + str( + entry.options.get( + CONF_OPTIMIZER_PROFILE, OPTIMIZER_PROFILE_BALANCED + ) + ), + PROFILE_SETTINGS[OPTIMIZER_PROFILE_BALANCED], + ), "battery_entities": battery_entities, "comfort_entities": comfort_entities, "optional_entities": optional_entities, diff --git a/src/custom_components/wattplan/optimizer/models.py b/src/custom_components/wattplan/optimizer/models.py index 85dd2db..761f5f7 100644 --- a/src/custom_components/wattplan/optimizer/models.py +++ b/src/custom_components/wattplan/optimizer/models.py @@ -67,6 +67,10 @@ def _validate_mode(cls, value): discharge_efficiency: float = Field( 1.0, description="Discharge efficiency fraction (0, 1]." ) + prefer_pv_surplus_charging: bool = Field( + False, + description="Whether PV surplus should be preferentially stored here.", + ) can_charge_from: int = Field( int(ChargeSource.PV), description="Bitmask of allowed charge sources: GRID=1, PV=2.", @@ -298,6 +302,15 @@ class OptimizationParams(BaseModel): ge=1, description="Rolling window size in slots used by comfort ON-slot accounting.", ) + throughput_cost_per_kwh: float = Field( + 0.0, description="Additional cost applied to charging/discharging throughput." + ) + action_deadband_kwh: float = Field( + 0.0, description="Commands smaller than this are treated as hold." + ) + mode_switch_cost: float = Field( + 0.0, description="Cost for switching between charge/hold/discharge behavior." + ) battery_entities: List[BatteryEntityParams] = Field( ..., description="List of battery-like entities." ) @@ -357,6 +370,16 @@ def _validate_state_blob(cls, value): @model_validator(mode="after") def _validate_cross_field_consistency(self): + for field_name in ( + "throughput_cost_per_kwh", + "action_deadband_kwh", + "mode_switch_cost", + ): + value = float(getattr(self, field_name)) + if not np.isfinite(value): + raise ValueError(f"{field_name} must be finite") + if value < 0.0: + raise ValueError(f"{field_name} must be >= 0") horizon = len(self.grid_import_price_per_kwh) solve_horizon = min(SOLVE_HORIZON_SLOTS, horizon) if len(self.grid_export_price_per_kwh) == 0: @@ -470,6 +493,10 @@ class BatteryEntity: discharge_curve_kwh: List[float] charge_efficiency: float discharge_efficiency: float + throughput_cost_per_kwh: float + action_deadband_kwh: float + mode_switch_cost: float + prefer_pv_surplus_charging: bool can_charge_from: int @@ -545,6 +572,12 @@ def _entity_fingerprint(battery_entities, comfort_entities, rolling_window_slots "discharge_curve_kwh": [float(v) for v in e.discharge_curve_kwh], "charge_efficiency": float(e.charge_efficiency), "discharge_efficiency": float(e.discharge_efficiency), + "throughput_cost_per_kwh": float(e.throughput_cost_per_kwh), + "action_deadband_kwh": float(e.action_deadband_kwh), + "mode_switch_cost": float(e.mode_switch_cost), + "prefer_pv_surplus_charging": bool( + e.prefer_pv_surplus_charging + ), "can_charge_from": int(e.can_charge_from), "target": ( { @@ -716,6 +749,12 @@ def normalize_calculation_input(params: OptimizationParams): discharge_curve_kwh=[float(v) for v in entity.discharge_curve_kwh], charge_efficiency=float(entity.charge_efficiency), discharge_efficiency=float(entity.discharge_efficiency), + throughput_cost_per_kwh=float(params.throughput_cost_per_kwh), + action_deadband_kwh=float(params.action_deadband_kwh), + mode_switch_cost=float(params.mode_switch_cost), + prefer_pv_surplus_charging=bool( + entity.prefer_pv_surplus_charging + ), can_charge_from=int(entity.can_charge_from), ) ) diff --git a/src/custom_components/wattplan/optimizer/mpc_power_optimizer.py b/src/custom_components/wattplan/optimizer/mpc_power_optimizer.py index 98dd201..15c9e85 100644 --- a/src/custom_components/wattplan/optimizer/mpc_power_optimizer.py +++ b/src/custom_components/wattplan/optimizer/mpc_power_optimizer.py @@ -335,6 +335,7 @@ def _solve_mpc_step( battery_entities, comfort_entities, battery_levels_now, + battery_states_now, comfort_levels_now, prev_comfort_on, comfort_off_streaks_now, @@ -389,7 +390,6 @@ def _solve_mpc_step( penalty_battery_target = 5000.0 penalty_comfort_target = 4000.0 penalty_switch = 0.0 - throughput_penalty = 0.0 A_eq = [] b_eq = [] @@ -405,6 +405,9 @@ def _solve_mpc_step( charge_eff = float(entity.charge_efficiency) discharge_eff = float(entity.discharge_efficiency) can_charge_from_grid, can_charge_from_pv = _charge_source_permissions(entity) + throughput_penalty = float(entity.throughput_cost_per_kwh) + mode_switch_cost = float(entity.mode_switch_cost) + previous_state = int(battery_states_now[b]) if battery_states_now.size else 0 for t in range(horizon): grid_upper = charge_limit if can_charge_from_grid else 0.0 @@ -422,6 +425,12 @@ def _solve_mpc_step( objective[var["min_slack"].start + t] += penalty_battery_min objective[var["target_under"].start] += penalty_battery_target objective[var["target_over"].start] += penalty_battery_target + if t == 0 and mode_switch_cost > 0.0: + if previous_state != 1: + objective[var["charge_grid"].start + t] += mode_switch_cost + objective[var["charge_pv"].start + t] += mode_switch_cost + if previous_state != 2: + objective[var["discharge"].start + t] += mode_switch_cost row = np.zeros(n_vars, dtype=np.float64) row[var["charge_grid"].start + t] = 1.0 @@ -666,6 +675,7 @@ def _apply_controls_step( charge_limit, discharge_limit = _battery_power_limits(entity, level) charge_eff = float(entity.charge_efficiency) discharge_eff = float(entity.discharge_efficiency) + action_deadband = float(entity.action_deadband_kwh) can_charge_from_grid, can_charge_from_pv = _charge_source_permissions(entity) requested_grid = max( @@ -697,15 +707,44 @@ def _apply_controls_step( pv_surplus_remaining = max(pv_surplus_remaining - actual_pv, 0.0) actual_grid = requested_grid + if actual_grid + actual_pv < action_deadband: + pv_surplus_remaining += actual_pv + actual_grid = 0.0 + actual_pv = 0.0 + + if ( + bool(entity.prefer_pv_surplus_charging) + and can_charge_from_pv + and pv_surplus_remaining > EPSILON + ): + extra_capacity = max(max_charge - (actual_grid + actual_pv), 0.0) + extra_pv = min(extra_capacity, pv_surplus_remaining) + if actual_grid + actual_pv + extra_pv >= action_deadband: + actual_pv += extra_pv + pv_surplus_remaining = max(pv_surplus_remaining - extra_pv, 0.0) + charge_grid_amounts[i] = actual_grid charge_pv_amounts[i] = actual_pv charge_amounts[i] = actual_grid + actual_pv + elif ( + bool(entity.prefer_pv_surplus_charging) + and can_charge_from_pv + and max_charge > 0.0 + and pv_surplus_remaining > EPSILON + ): + extra_pv = min(max_charge, pv_surplus_remaining) + if extra_pv >= action_deadband: + charge_pv_amounts[i] = extra_pv + charge_amounts[i] = extra_pv + pv_surplus_remaining = max(pv_surplus_remaining - extra_pv, 0.0) min_level = float(entity.minimum_kwh) available_for_discharge = max(level - min_level, 0.0) * discharge_eff - discharge_requests[i] = min( + discharge_request = min( requested_discharge, discharge_limit, available_for_discharge ) + if discharge_request >= action_deadband: + discharge_requests[i] = discharge_request demand_before_discharge = ( float(usage[t]) + float(np.sum(charge_amounts)) - float(solar_input[t]) @@ -720,6 +759,10 @@ def _apply_controls_step( else: discharge_amounts = discharge_requests + for i, entity in enumerate(battery_entities): + if discharge_amounts[i] < float(entity.action_deadband_kwh): + discharge_amounts[i] = 0.0 + battery_states = np.zeros(num_battery, dtype=np.int32) for i, entity in enumerate(battery_entities): charge_eff = float(entity.charge_efficiency) @@ -872,6 +915,11 @@ def _run_mpc( battery_entities=battery_entities, comfort_entities=[comfort_entities[i] for i in unlocked_indices], battery_levels_now=battery_levels[:, t], + battery_states_now=( + battery_states[:, t - 1] + if t > 0 + else np.zeros(num_battery, dtype=np.int32) + ), comfort_levels_now=comfort_levels[unlocked_indices, t] if unlocked_indices else np.zeros(0, dtype=np.float64), @@ -1071,7 +1119,9 @@ def _score_schedule( switch_penalty = 0.0 for i in range(len(battery_entities)): - switch_penalty += 0.05 * float(np.sum(np.diff(battery_states[i]) != 0)) + switch_penalty += float(battery_entities[i].mode_switch_cost) * float( + np.sum(np.diff(battery_states[i]) != 0) + ) for i in range(len(comfort_entities)): switch_penalty += 0.05 * float(np.sum(np.diff(comfort_enabled[i]) != 0)) diff --git a/src/custom_components/wattplan/strings.json b/src/custom_components/wattplan/strings.json index 34f60d9..b6da57d 100644 --- a/src/custom_components/wattplan/strings.json +++ b/src/custom_components/wattplan/strings.json @@ -11,11 +11,13 @@ "description": "Create one WattPlan setup for your home.", "submit": "Next", "data": { + "optimizer_profile": "Optimizer profile", "name": "Setup name", "slot_minutes": "Resolution (minutes)", "hours_to_plan": "How many hours ahead should the plan cover" }, "data_description": { + "optimizer_profile": "Choose how actively WattPlan should use batteries.\n\n- Aggressive: Favors savings and accepts more battery movement.\n- Balanced: Good default for most homes; balances savings and calmer battery behavior.\n- Conservative: Avoids smaller moves and keeps battery behavior steadier.\n\n[Read more here](https://github.com/LordMike/WattPlan/blob/main/docs/optimizer-profiles.md).", "name": "This name will identify this WattPlan setup in Home Assistant.", "slot_minutes": "This is the plan resolution. A lower value means more detailed plans and more required forecast values.", "hours_to_plan": "WattPlan needs forecasts that cover the full selected period." @@ -707,12 +709,12 @@ "title": "Planner settings", "description": "Edit planner settings and forecast sources for this setup.", "menu_options": { - "planner_core": "Core planner settings", + "planner_timers": "Scheduler settings", + "planner_core": "General settings", "source_price": "Price source", "source_export_price": "Export price source", "source_usage": "Usage source", "source_pv": "Solar source", - "planner_timers": "Planner timer settings", "battery_entities": "Battery entities", "comfort_entities": "Comfort entities", "optional_entities": "Optional entities", @@ -720,13 +722,15 @@ } }, "planner_core": { - "title": "Core settings", + "title": "General settings", "description": "Set interval size and planning horizon. These values define how many forecast values are required.", "data": { + "optimizer_profile": "Optimizer profile", "slot_minutes": "Resolution (minutes)", "hours_to_plan": "How many hours ahead should the plan cover" }, "data_description": { + "optimizer_profile": "Choose how actively WattPlan should use batteries.\n\n- Aggressive: Favors savings and accepts more battery movement.\n- Balanced: Good default for most homes; balances savings and calmer battery behavior.\n- Conservative: Avoids smaller moves and keeps battery behavior steadier.\n\n[Read more here](https://github.com/LordMike/WattPlan/blob/main/docs/optimizer-profiles.md).", "slot_minutes": "This is the plan resolution. A lower value means more detailed plans and more required forecast values.", "hours_to_plan": "WattPlan needs forecasts that cover the full selected period." } @@ -1108,7 +1112,7 @@ } }, "planner_timers": { - "title": "Planner timers", + "title": "Scheduler settings", "description": "When enabled, planning and action publishing run every {slot_minutes} minutes.", "data": { "planning_enabled": "Planning enabled", @@ -1456,11 +1460,13 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency" + "discharge_efficiency": "Discharge efficiency", + "prefer_pv_surplus_charging": "Prefer PV surplus charging" }, "data_description": { "charge_efficiency": "Fraction of charging energy that ends up stored in the battery.", - "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging.", + "prefer_pv_surplus_charging": "Recommended for EV batteries or batteries that should generally end up charged. When enabled, any available PV surplus is routed into this battery instead of timing small exports for later recharge." } } } @@ -1492,11 +1498,13 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency" + "discharge_efficiency": "Discharge efficiency", + "prefer_pv_surplus_charging": "Prefer PV surplus charging" }, "data_description": { "charge_efficiency": "Fraction of charging energy that ends up stored in the battery.", - "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging.", + "prefer_pv_surplus_charging": "Recommended for EV batteries or batteries that should generally end up charged. When enabled, any available PV surplus is routed into this battery instead of timing small exports for later recharge." } } } diff --git a/src/custom_components/wattplan/translations/en.json b/src/custom_components/wattplan/translations/en.json index 048d75f..813d052 100644 --- a/src/custom_components/wattplan/translations/en.json +++ b/src/custom_components/wattplan/translations/en.json @@ -11,11 +11,13 @@ "description": "Create one WattPlan setup for your home.", "submit": "Next", "data": { + "optimizer_profile": "Optimizer profile", "name": "Setup name", "slot_minutes": "Resolution (minutes)", "hours_to_plan": "How many hours ahead should the plan cover" }, "data_description": { + "optimizer_profile": "Choose how actively WattPlan should use batteries.\n\n- Aggressive: Favors savings and accepts more battery movement.\n- Balanced: Good default for most homes; balances savings and calmer battery behavior.\n- Conservative: Avoids smaller moves and keeps battery behavior steadier.\n\n[Read more here](https://github.com/LordMike/WattPlan/blob/main/docs/optimizer-profiles.md).", "name": "This name will identify this WattPlan setup in Home Assistant.", "slot_minutes": "This is the plan resolution. A lower value means more detailed plans and more required forecast values.", "hours_to_plan": "WattPlan needs forecasts that cover the full selected period." @@ -707,12 +709,12 @@ "title": "Planner settings", "description": "Edit planner settings and forecast sources for this setup.", "menu_options": { - "planner_core": "Core planner settings", + "planner_timers": "Scheduler settings", + "planner_core": "General settings", "source_price": "Price source", "source_export_price": "Export price source", "source_usage": "Usage source", "source_pv": "Solar source", - "planner_timers": "Planner timer settings", "battery_entities": "Battery entities", "comfort_entities": "Comfort entities", "optional_entities": "Optional entities", @@ -720,13 +722,15 @@ } }, "planner_core": { - "title": "Core settings", + "title": "General settings", "description": "Set interval size and planning horizon. These values define how many forecast values are required.", "data": { + "optimizer_profile": "Optimizer profile", "slot_minutes": "Resolution (minutes)", "hours_to_plan": "How many hours ahead should the plan cover" }, "data_description": { + "optimizer_profile": "Choose how actively WattPlan should use batteries.\n\n- Aggressive: Favors savings and accepts more battery movement.\n- Balanced: Good default for most homes; balances savings and calmer battery behavior.\n- Conservative: Avoids smaller moves and keeps battery behavior steadier.\n\n[Read more here](https://github.com/LordMike/WattPlan/blob/main/docs/optimizer-profiles.md).", "slot_minutes": "This is the plan resolution. A lower value means more detailed plans and more required forecast values.", "hours_to_plan": "WattPlan needs forecasts that cover the full selected period." } @@ -1108,7 +1112,7 @@ } }, "planner_timers": { - "title": "Planner timers", + "title": "Scheduler settings", "description": "When enabled, planning and action publishing run every {slot_minutes} minutes.", "data": { "planning_enabled": "Planning enabled", @@ -1456,11 +1460,13 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency" + "discharge_efficiency": "Discharge efficiency", + "prefer_pv_surplus_charging": "Prefer PV surplus charging" }, "data_description": { "charge_efficiency": "Fraction of charging energy that ends up stored in the battery.", - "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging.", + "prefer_pv_surplus_charging": "Recommended for EV batteries or batteries that should generally end up charged. When enabled, any available PV surplus is routed into this battery instead of timing small exports for later recharge." } } } @@ -1492,11 +1498,13 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency" + "discharge_efficiency": "Discharge efficiency", + "prefer_pv_surplus_charging": "Prefer PV surplus charging" }, "data_description": { "charge_efficiency": "Fraction of charging energy that ends up stored in the battery.", - "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging.", + "prefer_pv_surplus_charging": "Recommended for EV batteries or batteries that should generally end up charged. When enabled, any available PV surplus is routed into this battery instead of timing small exports for later recharge." } } } diff --git a/tests/optimizer/test_optimizer_scenarios.py b/tests/optimizer/test_optimizer_scenarios.py index 697a25c..ccc7493 100644 --- a/tests/optimizer/test_optimizer_scenarios.py +++ b/tests/optimizer/test_optimizer_scenarios.py @@ -474,8 +474,8 @@ def test_feed_in_prices_shift_pv_charging_to_lower_export_value_slots(): "initial_kwh": 0.0, "minimum_kwh": 0.0, "capacity_kwh": 1.0, - "charge_curve_kwh": [1.0], - "discharge_curve_kwh": [1.0], + "charge_curve_kwh": [0.05], + "discharge_curve_kwh": [0.05], "can_charge_from": 2, } ], @@ -800,8 +800,8 @@ def test_validation_rejects_series_length_mismatch(): "initial_kwh": 1.0, "minimum_kwh": 0.0, "capacity_kwh": 2.0, - "charge_curve_kwh": [1.0], - "discharge_curve_kwh": [1.0], + "charge_curve_kwh": [0.05], + "discharge_curve_kwh": [0.05], "can_charge_from": 1, } ], @@ -1538,3 +1538,108 @@ def test_discharge_efficiency_reduces_deliverable_energy(): lossy["projections"]["per_slot"][0]["projected_cost"] > efficient["projections"]["per_slot"][0]["projected_cost"] ) + + +def test_conservative_profile_reduces_marginal_arbitrage(): + base_payload = { + "grid_import_price_per_kwh": [0.1, 0.1, 0.8, 0.8], + "grid_export_price_per_kwh": [0.0, 0.0, 0.0, 0.0], + "solar_input_kwh": [0.0, 0.0, 0.0, 0.0], + "usage_kwh": [0.0, 0.0, 1.0, 1.0], + "battery_entities": [ + { + "name": "b", + "initial_kwh": 0.0, + "minimum_kwh": 0.0, + "capacity_kwh": 1.0, + "charge_curve_kwh": [0.05], + "discharge_curve_kwh": [0.05], + "can_charge_from": 1, + } + ], + "comfort_entities": [], + } + + low_cost = _run_optimizer(base_payload) + conservative_payload = json.loads(json.dumps(base_payload)) + conservative_payload["throughput_cost_per_kwh"] = 0.08 + conservative_payload["action_deadband_kwh"] = 0.1 + conservative_payload["mode_switch_cost"] = 0.03 + conservative = _run_optimizer(conservative_payload) + + assert low_cost["entities"][0]["schedule"][0]["state"] == "charge" + assert any( + point["state"] == "discharge" for point in low_cost["entities"][0]["schedule"] + ) + assert all( + point["state"] == "hold" for point in conservative["entities"][0]["schedule"] + ) + + +def test_conservative_profile_suppresses_tiny_battery_moves(): + base_payload = { + "grid_import_price_per_kwh": [0.1, 0.1, 1.0, 1.0], + "grid_export_price_per_kwh": [0.0, 0.0, 0.0, 0.0], + "solar_input_kwh": [0.0, 0.0, 0.0, 0.0], + "usage_kwh": [0.0, 0.0, 0.05, 0.05], + "battery_entities": [ + { + "name": "b", + "initial_kwh": 0.0, + "minimum_kwh": 0.0, + "capacity_kwh": 1.0, + "charge_curve_kwh": [0.05], + "discharge_curve_kwh": [0.05], + "can_charge_from": 1, + } + ], + "comfort_entities": [], + } + + no_deadband = _run_optimizer(base_payload) + conservative_payload = json.loads(json.dumps(base_payload)) + conservative_payload["throughput_cost_per_kwh"] = 0.08 + conservative_payload["action_deadband_kwh"] = 0.1 + conservative_payload["mode_switch_cost"] = 0.03 + with_profile = _run_optimizer(conservative_payload) + + assert no_deadband["entities"][0]["schedule"][0]["state"] == "charge" + assert any( + point["state"] == "discharge" + for point in no_deadband["entities"][0]["schedule"] + ) + assert all( + point["state"] == "hold" for point in with_profile["entities"][0]["schedule"] + ) + + +def test_prefer_pv_surplus_charging_sinks_surplus_into_battery(): + base_payload = { + "grid_import_price_per_kwh": [0.2, 0.2, 0.2, 0.2], + "grid_export_price_per_kwh": [1.0, 0.8, 0.8, 0.8], + "solar_input_kwh": [1.0, 0.0, 0.0, 0.0], + "usage_kwh": [0.0, 0.0, 0.0, 0.0], + "battery_entities": [ + { + "name": "ev", + "initial_kwh": 0.0, + "minimum_kwh": 0.0, + "capacity_kwh": 1.0, + "charge_curve_kwh": [1.0], + "discharge_curve_kwh": [0.0], + "can_charge_from": 2, + } + ], + "comfort_entities": [], + } + + baseline = _run_optimizer(base_payload) + pv_sink_payload = json.loads(json.dumps(base_payload)) + pv_sink_payload["battery_entities"][0]["prefer_pv_surplus_charging"] = True + pv_sink = _run_optimizer(pv_sink_payload) + + assert baseline["entities"][0]["schedule"][0]["state"] == "hold" + assert baseline["entities"][0]["schedule"][0]["level"] == pytest.approx(0.0) + assert pv_sink["entities"][0]["schedule"][0]["state"] == "charge" + assert pv_sink["entities"][0]["schedule"][0]["charge_source"] == 2 + assert pv_sink["entities"][0]["schedule"][0]["level"] == pytest.approx(1.0)