From be5ca65f398b01c9568472f75224d44982761750 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 22 Apr 2026 13:51:15 +0200 Subject: [PATCH 1/6] Change battery actions to policy states --- .../wattplan/coordinator_logic/projection.py | 24 +-- custom_components/wattplan/flows/forms.py | 3 - .../wattplan/flows/source_shared.py | 6 - .../wattplan/optimizer/models.py | 6 +- .../wattplan/optimizer/mpc_power_optimizer.py | 141 ++++++++++++++--- custom_components/wattplan/sensors/actions.py | 8 +- custom_components/wattplan/strings.json | 14 +- .../wattplan/test_plan_invariants.py | 10 +- .../wattplan/translations/en.json | 14 +- docs/entities-and-services.md | 2 +- docs/extras.md | 59 ++++--- docs/optimizer-api.md | 23 ++- docs/optimizer-profiles.md | 13 -- tests/integration/test_integration_e2e.py | 4 +- tests/integration/test_integration_runtime.py | 71 +++++---- tests/optimizer/test_optimizer_scenarios.py | 148 +++++++++++++----- 16 files changed, 364 insertions(+), 182 deletions(-) diff --git a/custom_components/wattplan/coordinator_logic/projection.py b/custom_components/wattplan/coordinator_logic/projection.py index 7c9d127..f1feaa2 100644 --- a/custom_components/wattplan/coordinator_logic/projection.py +++ b/custom_components/wattplan/coordinator_logic/projection.py @@ -63,12 +63,12 @@ def planner_output_from_result( next_action_timestamp = next_change[0] if next_change is not None else None next_point = next_change[1] if next_change is not None else None next_action = ( - str(next_point.get("state", "hold")) + str(next_point.get("state", "self_consume")) if isinstance(next_point, dict) else None ) batteries[subentry_id] = { - "action": str(current.get("state", "hold")), + "action": str(current.get("state", "self_consume")), "next_action_timestamp": ( next_action_timestamp.isoformat() if next_action_timestamp is not None @@ -264,7 +264,11 @@ def _build_plan_details_payload( plan_details[f"{key_base}_action"] = [ self._map_action_code(str(action)) for action in self._series_from_schedule( - schedule, horizon_slots, "state", default="hold", stringify=True + schedule, + horizon_slots, + "state", + default="self_consume", + stringify=True, ) ] plan_details[f"{key_base}_level_kwh"] = self._rounded_series( @@ -370,8 +374,8 @@ def _aggregate_plan_details_series( aggregated.append(round(sum(float(value) for value in chunk), 2)) elif key.endswith("_action"): actions = {str(value) for value in chunk if isinstance(value, str)} - active_actions = sorted(action for action in actions if action != "h") - aggregated.append("+".join(active_actions) if active_actions else "h") + active_actions = sorted(action for action in actions if action != "sc") + aggregated.append("+".join(active_actions) if active_actions else "sc") else: aggregated.append(chunk[-1]) return aggregated @@ -457,9 +461,7 @@ def _next_change_point( def _map_action_code(self, action: str) -> str: return { - "charge_grid": "c_g", - "charge_pv": "c_p", - "charge_grid_pv": "c_gp", - "discharge": "d", - "hold": "h", - }.get(action, "h") + "grid_charge": "gc", + "preserve": "p", + "self_consume": "sc", + }.get(action, "sc") diff --git a/custom_components/wattplan/flows/forms.py b/custom_components/wattplan/flows/forms.py index 92f85a6..c25603b 100644 --- a/custom_components/wattplan/flows/forms.py +++ b/custom_components/wattplan/flows/forms.py @@ -85,9 +85,6 @@ 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/custom_components/wattplan/flows/source_shared.py b/custom_components/wattplan/flows/source_shared.py index 992807c..296d38a 100644 --- a/custom_components/wattplan/flows/source_shared.py +++ b/custom_components/wattplan/flows/source_shared.py @@ -72,7 +72,6 @@ CONF_OPTIONS_COUNT, CONF_PLANNING_ENABLED, CONF_OPTIMIZER_PROFILE, - CONF_PREFER_PV_SURPLUS_CHARGING, CONF_PROVIDERS, CONF_RESAMPLE_MODE, CONF_ROLLING_WINDOW_HOURS, @@ -1396,7 +1395,6 @@ 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( @@ -1423,10 +1421,6 @@ def _battery_schema() -> vol.Schema: mode=selector.NumberSelectorMode.BOX, ) ), - vol.Required( - CONF_PREFER_PV_SURPLUS_CHARGING, - default=False, - ): selector.BooleanSelector(), } ), {"collapsed": True}, diff --git a/custom_components/wattplan/optimizer/models.py b/custom_components/wattplan/optimizer/models.py index 761f5f7..e9d9cc3 100644 --- a/custom_components/wattplan/optimizer/models.py +++ b/custom_components/wattplan/optimizer/models.py @@ -69,7 +69,7 @@ def _validate_mode(cls, value): ) prefer_pv_surplus_charging: bool = Field( False, - description="Whether PV surplus should be preferentially stored here.", + description="Internal/deferred PV surplus sink hint; not a public action state.", ) can_charge_from: int = Field( int(ChargeSource.PV), @@ -306,10 +306,10 @@ class OptimizationParams(BaseModel): 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." + 0.0, description="Commands smaller than this are treated as neutral flow." ) mode_switch_cost: float = Field( - 0.0, description="Cost for switching between charge/hold/discharge behavior." + 0.0, description="Cost for switching between modeled charge/idle/discharge flow." ) battery_entities: List[BatteryEntityParams] = Field( ..., description="List of battery-like entities." diff --git a/custom_components/wattplan/optimizer/mpc_power_optimizer.py b/custom_components/wattplan/optimizer/mpc_power_optimizer.py index d934403..1633705 100644 --- a/custom_components/wattplan/optimizer/mpc_power_optimizer.py +++ b/custom_components/wattplan/optimizer/mpc_power_optimizer.py @@ -3,6 +3,7 @@ import numpy as np from .models import ( + BatteryEntity, CalculationInput, ChargeSource, OptimizationParams, @@ -1219,28 +1220,118 @@ def _optional_entity_options(entity, grid_import_prices, baseline_net_import): ] -def _battery_schedule_state(result, battery_index: int, timeslot: int) -> str: - """Return the serialized battery action state for one schedule slot.""" +def _battery_schedule_state( + result, + entity: BatteryEntity, + battery_index: int, + timeslot: int, + *, + usage=None, + solar_input=None, + comfort_entities=None, +) -> str: + """Return the serialized battery policy state for one schedule slot.""" + action_deadband = max(float(entity.action_deadband_kwh), EPSILON) + if result["battery_charge_grid"][battery_index, timeslot] > action_deadband: + return "grid_charge" + if _battery_should_preserve( + result, + entity, + battery_index, + timeslot, + usage=usage, + solar_input=solar_input, + comfort_entities=comfort_entities, + action_deadband=action_deadband, + ): + return "preserve" + return "self_consume" + + +def _battery_should_preserve( + result, + entity: BatteryEntity, + battery_index: int, + timeslot: int, + *, + usage, + solar_input, + comfort_entities, + action_deadband: float, +) -> bool: + """Return True when the model gives a positive reason to block discharge. + + The optimizer does not currently expose a shadow price for stored battery + energy, so preserve is intentionally conservative. We only emit it for a + target/minimum constraint or when the plan faces load now, chooses not to + discharge this battery, and schedules that same battery to discharge later. + Neutral/PV-only slots stay self_consume so unexpected real load can still be + served by the inverter. + """ battery_state = int(result["battery_states"][battery_index, timeslot]) - if battery_state == 0: - return "hold" - if battery_state == 1: - charge_ingress = int( - (1 if result["battery_charge_grid"][battery_index, timeslot] > EPSILON else 0) - | (2 if result["battery_charge_pv"][battery_index, timeslot] > EPSILON else 0) - ) - if charge_ingress == 1: - return "charge_grid" - if charge_ingress == 2: - return "charge_pv" - if charge_ingress == 3: - return "charge_grid_pv" - raise ValueError( - "battery schedule serialization encountered charging state without charge ingress" - ) if battery_state == 2: - return "discharge" - raise ValueError(f"battery schedule serialization encountered unknown state {battery_state}") + return False + + level = float(result["battery_levels"][battery_index, timeslot]) + minimum_kwh = float(entity.minimum_kwh) + discharge_eff = float(entity.discharge_efficiency) + available_for_discharge = max(level - minimum_kwh, 0.0) * discharge_eff + + if entity.target is not None and timeslot <= int(entity.target.timeslot): + target_floor = float(entity.target.soc_kwh) - float(entity.target.tolerance_kwh) + if ( + entity.target.mode in {"at_least", "exact"} + and level <= target_floor + action_deadband + ): + return True + + if minimum_kwh > EPSILON and available_for_discharge <= action_deadband: + return _slot_has_model_load( + result, + timeslot, + usage=usage, + solar_input=solar_input, + comfort_entities=comfort_entities, + action_deadband=action_deadband, + ) + + future_discharge = float( + np.sum(result["battery_discharge"][battery_index, timeslot + 1 :]) + ) + if future_discharge <= action_deadband: + return False + if result["battery_discharge"][battery_index, timeslot] > action_deadband: + return False + return _slot_has_model_load( + result, + timeslot, + usage=usage, + solar_input=solar_input, + comfort_entities=comfort_entities, + action_deadband=action_deadband, + ) + + +def _slot_has_model_load( + result, + timeslot: int, + *, + usage, + solar_input, + comfort_entities, + action_deadband: float, +) -> bool: + """Return True when the modeled site has non-PV-covered load this slot.""" + if usage is None or solar_input is None: + return False + comfort_load = 0.0 + for i, comfort in enumerate(comfort_entities or []): + if result["comfort_enabled"][i, timeslot]: + comfort_load += float(comfort.power_usage_kwh) + modeled_load = ( + float(usage[timeslot]) + comfort_load - float(solar_input[timeslot]) + ) + return modeled_load > action_deadband def optimize_internal(normalized: CalculationInput): @@ -1336,7 +1427,15 @@ def optimize_internal(normalized: CalculationInput): "type": "battery", "schedule": [ { - "state": _battery_schedule_state(result, i, t), + "state": _battery_schedule_state( + result, + entity, + i, + t, + usage=usage, + solar_input=solar_input, + comfort_entities=comfort_entities, + ), "level": float(result["battery_levels"][i, t + 1]), } for t in range(total_steps) diff --git a/custom_components/wattplan/sensors/actions.py b/custom_components/wattplan/sensors/actions.py index 7ddef4b..34ee8e9 100644 --- a/custom_components/wattplan/sensors/actions.py +++ b/custom_components/wattplan/sensors/actions.py @@ -14,11 +14,9 @@ from .common import as_datetime, entry_device_info BATTERY_ACTION_STATES = [ - "hold", - "discharge", - "charge_grid", - "charge_pv", - "charge_grid_pv", + "preserve", + "self_consume", + "grid_charge", ] diff --git a/custom_components/wattplan/strings.json b/custom_components/wattplan/strings.json index 971a654..0086b11 100644 --- a/custom_components/wattplan/strings.json +++ b/custom_components/wattplan/strings.json @@ -1120,7 +1120,7 @@ }, "data_description": { "planning_enabled": "When enabled, optimization runs every {slot_minutes} minutes and publishes the new plan to WattPlan entities.", - "action_emission_enabled": "When enabled, action states like charge_grid/charge_pv/charge_grid_pv/discharge/hold are published every {slot_minutes} minutes." + "action_emission_enabled": "When enabled, battery policy states like preserve/self_consume/grid_charge are published every {slot_minutes} minutes." } }, "battery_entities": { @@ -1460,13 +1460,11 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency", - "prefer_pv_surplus_charging": "Prefer PV surplus charging" + "discharge_efficiency": "Discharge efficiency" }, "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.", - "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." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." } } } @@ -1498,13 +1496,11 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency", - "prefer_pv_surplus_charging": "Prefer PV surplus charging" + "discharge_efficiency": "Discharge efficiency" }, "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.", - "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." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." } } } diff --git a/custom_components/wattplan/test_plan_invariants.py b/custom_components/wattplan/test_plan_invariants.py index f6cdf16..2055901 100644 --- a/custom_components/wattplan/test_plan_invariants.py +++ b/custom_components/wattplan/test_plan_invariants.py @@ -25,13 +25,11 @@ def assert_plan_invariants(result: dict[str, Any]) -> dict[str, Any]: if not isinstance(point, dict): continue - state = str(point.get("state", "hold")) + state = str(point.get("state", "self_consume")) assert state in { - "hold", - "discharge", - "charge_grid", - "charge_pv", - "charge_grid_pv", + "preserve", + "self_consume", + "grid_charge", }, f"battery {entity.get('name')} schedule[{index}] has invalid state={state}" return result diff --git a/custom_components/wattplan/translations/en.json b/custom_components/wattplan/translations/en.json index f13af67..294b0dc 100644 --- a/custom_components/wattplan/translations/en.json +++ b/custom_components/wattplan/translations/en.json @@ -1120,7 +1120,7 @@ }, "data_description": { "planning_enabled": "When enabled, optimization runs every {slot_minutes} minutes and publishes the new plan to WattPlan entities.", - "action_emission_enabled": "When enabled, action states like charge_grid/charge_pv/charge_grid_pv/discharge/hold are published every {slot_minutes} minutes." + "action_emission_enabled": "When enabled, battery policy states like preserve/self_consume/grid_charge are published every {slot_minutes} minutes." } }, "battery_entities": { @@ -1460,13 +1460,11 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency", - "prefer_pv_surplus_charging": "Prefer PV surplus charging" + "discharge_efficiency": "Discharge efficiency" }, "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.", - "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." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." } } } @@ -1498,13 +1496,11 @@ "name": "Advanced battery behavior", "data": { "charge_efficiency": "Charge efficiency", - "discharge_efficiency": "Discharge efficiency", - "prefer_pv_surplus_charging": "Prefer PV surplus charging" + "discharge_efficiency": "Discharge efficiency" }, "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.", - "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." + "discharge_efficiency": "Fraction of stored energy that can be delivered when discharging." } } } diff --git a/docs/entities-and-services.md b/docs/entities-and-services.md index 2307d13..adf5805 100644 --- a/docs/entities-and-services.md +++ b/docs/entities-and-services.md @@ -40,7 +40,7 @@ These exist once per configured battery: | Entity | Purpose | | --- | --- | -| `sensor.__action` | Current planned action: `hold`, `discharge`, `charge_grid`, `charge_pv`, or `charge_grid_pv`. WattPlan updates this entity on its planning schedule so **your own automation can translate the planned action into a real inverter or battery command**. The state itself now encodes the planned charging ingress, so there is no separate `charge_source` attribute to inspect. | +| `sensor.__action` | Current battery-control policy: `preserve`, `self_consume`, or `grid_charge`. WattPlan updates this entity on its planning schedule so **your own automation can translate the policy into a real inverter or battery command**. The state is a policy derived from the plan, not a raw forecast battery-flow value. | | `sensor.__target` | User-supplied target SoC in kWh. Includes a `by` attribute with the requested deadline and returns `unknown` when no active target is set. | ## Comfort Load Entities diff --git a/docs/extras.md b/docs/extras.md index 8d10062..d47f5eb 100644 --- a/docs/extras.md +++ b/docs/extras.md @@ -14,19 +14,17 @@ All of these are configured inside the WattPlan integration UI. WattPlan then ex ## Batteries ### What They Are -Batteries model controllable storage. WattPlan plans battery behavior as: -- `charge_grid` -- `charge_pv` -- `charge_grid_pv` -- `discharge` -- `hold` +Batteries model controllable storage. WattPlan exposes each battery's action as an inverter-control policy: +- `preserve` +- `self_consume` +- `grid_charge` It also tracks battery targets and timing data so you can expose planned behavior in the UI and automations. ### When to Use Them Use a battery when: - You have a home battery or battery-backed inverter. -- Your inverter or control stack can be told to charge from grid or PV, discharge, or hold. +- Your inverter or control stack can be told to allow/block battery discharge and enable/disable scheduled grid charging. - You want WattPlan to shift energy based on price, usage, and PV availability. ### How to Configure Them @@ -49,18 +47,43 @@ The battery action sensor is the key one for control. Your automation should rea **Typical Pattern:** 1. Create an automation that triggers when the WattPlan battery action entity changes. 2. Read the action value from WattPlan. -3. Map `charge_grid`, `charge_pv`, `charge_grid_pv`, `discharge`, or `hold` to your inverter's controls. +3. Map `preserve`, `self_consume`, or `grid_charge` to your inverter's controls. 4. Call the real inverter service, script, switch, or helper sequence. -**Example Mapping Concept:** -- `charge_grid` -> Set inverter/battery system to charge from the grid. -- `charge_pv` -> Set inverter/battery system to charge from PV surplus. -- `charge_grid_pv` -> Set inverter/battery system to allow charging from either grid or PV. -- `discharge` -> Set inverter/battery system to discharge/export/self-consume mode. -- `hold` -> Stop active charging/discharging and leave the battery neutral. +### Battery Policy States +The battery action sensor exposes policy, not raw measured or forecast battery flow. A slot where the plan shows no modeled battery delta often still emits `self_consume`, because the inverter should normally be allowed to cover real load that differs from the forecast. + +| Policy | Meaning | +| --- | --- | +| `preserve` | Save stored energy for future value or target constraints. Your automation should prevent this battery from discharging. PV charging may still be allowed by your inverter setup. | +| `self_consume` | Normal battery operation. Allow this battery to cover real load. Do not request grid charging. This is the default policy when the plan has no positive reason to preserve or grid-charge. | +| `grid_charge` | Request or allow grid charging for this battery and prevent the battery from being spent while doing so. | + +PV surplus handling is not a battery action state in this version. PV export is a site-level decision, especially with multiple batteries, and is deferred for a future site-level policy design. Treat PV charging as normal inverter behavior unless your own automation needs a different device-specific rule. The exact translation depends on your inverter integration. WattPlan does not directly control every battery platform; it publishes the intended action and lets your automations bridge that to your actual system. +### Solar Assistant/MQTT/Inverter Example +This example describes one practical setup: Home Assistant entities exposed by Solar Assistant over MQTT for a Deye-compatible inverter. It is not universal. Other inverter brands may map the same three WattPlan policies to different entities or services. + +In this style of setup, the inverter time-of-use schedule controls can be more reliable than direct mode controls. A time-of-use capacity point is used to allow or block battery discharge, and a grid charge point switch is used to enable scheduled grid charging. Max charge/current entities may also exist, but grid charge current can often be treated as a configured cap instead of being changed on every policy update. + +Generic policy mapping: + +| WattPlan policy | Discharge allowed | Grid charging | Battery charging from PV | +| --- | --- | --- | --- | +| `preserve` | No | Off | Allowed/normal | +| `self_consume` | Yes | Off | Allowed/normal | +| `grid_charge` | No | On | Allowed | + +Example time-of-use mapping: + +| WattPlan policy | Time-of-use capacity point | Grid charge point | Charge current | +| --- | --- | --- | --- | +| `preserve` | High, for example `100%`, to prevent discharge | Off | Normal/static unless the setup needs otherwise | +| `self_consume` | Normal minimum, for example `10%` | Off | Normal/static | +| `grid_charge` | High, for example `100%`, to preserve while charging | On | Configured normal charging cap | + ## Comfort Loads ### What They Are @@ -154,11 +177,9 @@ Usually no. WattPlan publishes the intended action or time suggestion. You conne ### How do I control a battery inverter with WattPlan? Use the WattPlan battery action entity as the planner output. Then create an automation that maps: -- `charge_grid` -- `charge_pv` -- `charge_grid_pv` -- `discharge` -- `hold` +- `preserve` +- `self_consume` +- `grid_charge` To whatever your inverter integration actually supports. That might be: diff --git a/docs/optimizer-api.md b/docs/optimizer-api.md index 9d1ff21..34ebe95 100644 --- a/docs/optimizer-api.md +++ b/docs/optimizer-api.md @@ -18,7 +18,7 @@ All time-indexed fields use **timeslots**. ## Conceptual Model The solve combines three kinds of entities: -- **Battery Entities:** Controllable storage that can charge, discharge, or hold. They can absorb PV surplus when economically/physically allowed. +- **Battery Entities:** Controllable storage with modeled charge/discharge flows and serialized policy states for inverter control. - **Comfort Entities:** Postponable-but-required comfort loads, such as heating or hot water that can be shifted, but should not violate minimum comfort. - **Optional Entities:** User suggestions for "might run" appliances, such as dishwashers or dryers. They are advisory only and do **not** affect the main optimized schedule. The output is a list of best candidate start timeslots. @@ -39,7 +39,7 @@ result = optimize(params) | `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. | +| `action_deadband_kwh` | `float` | No | `0.0` | Finite, `>= 0` | Modeled flow commands smaller than this are treated as neutral flow. | | `mode_switch_cost` | `float` | No | `0.0` | Finite, `>= 0` | Extra cost on changing battery behavior between slots. | | `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. | @@ -64,7 +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. | +| `prefer_pv_surplus_charging` | `bool` | No | `false` | - | Internal/deferred hint for routing PV surplus into this battery. This is not exposed as a battery action state and should not be used as a user-facing control contract. | | `can_charge_from` | `int` | No | `2` | `0`, `1`, `2`, `3` | Allowed charging-ingress flags (`1=GRID`, `2=PV`, `3=GRID|PV`; `0` means charging disabled). | **Curve Unit Note:** @@ -128,7 +128,7 @@ Optional entities provide advisory start-time suggestions and do not change the "usage_kwh": [1.2, 1.1, 1.0, 0.9, 1.0, 1.2, 1.3, 1.4], "rolling_window_slots": 96, - // Controllable storage: can charge from grid/PV, discharge, or hold and absorb PV surplus. + // Controllable storage: the model tracks grid/PV charge and discharge flows. "battery_entities": [ { "name": "home_battery", @@ -212,9 +212,22 @@ Optional entities provide advisory start-time suggestions and do not change the | `optional_entity_options` | `list[dict]` | Advisory start options per optional entity. | | `state` | `str` | Opaque base64 state for next call. | +### Battery Policy States +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 for target/minimum constraints or modeled future value. PV charging may still be allowed by the user's inverter setup. | +| `self_consume` | Normal battery operation. Allow this battery to cover real load. Do not request grid charging. This is also the default when the model has no positive reason to preserve or grid-charge. | +| `grid_charge` | Request or allow grid charging for this battery and prevent the battery from being spent while doing so. | + +PV surplus charging is implicit/normal battery behavior, not a primary action state. PV export is site-level and multi-battery-sensitive, so a dedicated PV export policy is deferred to a future site-level design. + +The current preserve inference is intentionally conservative because the optimizer does not expose a shadow price for stored battery energy. `grid_charge` is emitted when modeled grid charging for that battery is above the action deadband. `preserve` is emitted only when a target/minimum constraint or future scheduled discharge gives a concrete model-backed reason to block discharge. Otherwise WattPlan emits `self_consume`, including slots with forecast zero battery flow. + ### Notes on `entities` and `optional_entity_options` - `entities` is the actual optimized schedule. -- Battery schedule points encode charging source directly in `state`: `charge_grid`, `charge_pv`, `charge_grid_pv`, `discharge`, or `hold`. +- Battery schedule points encode policy directly in `state`: `preserve`, `self_consume`, or `grid_charge`. - `optional_entity_options` is advisory and computed on top of that baseline. - Optional entities do not affect each other and do not modify `entities`. diff --git a/docs/optimizer-profiles.md b/docs/optimizer-profiles.md index cacc27a..8205faf 100644 --- a/docs/optimizer-profiles.md +++ b/docs/optimizer-profiles.md @@ -55,16 +55,3 @@ 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/tests/integration/test_integration_e2e.py b/tests/integration/test_integration_e2e.py index a1366df..1e10b8d 100644 --- a/tests/integration/test_integration_e2e.py +++ b/tests/integration/test_integration_e2e.py @@ -116,8 +116,8 @@ def _fake_optimize_with_entities(params: Any) -> dict[str, object]: "name": _name_of(battery), "type": "battery", "schedule": [ - {"state": "charge_grid", "level": 5.0}, - {"state": "hold", "level": 5.0}, + {"state": "grid_charge", "level": 5.0}, + {"state": "self_consume", "level": 5.0}, ], } for battery in battery_entities diff --git a/tests/integration/test_integration_runtime.py b/tests/integration/test_integration_runtime.py index 5dd00b7..2d22d33 100644 --- a/tests/integration/test_integration_runtime.py +++ b/tests/integration/test_integration_runtime.py @@ -118,10 +118,10 @@ def _fake_optimize(_params: object) -> dict[str, object]: "name": "battery", "type": "battery", "schedule": [ - {"state": "charge_grid", "level": 5.1}, - {"state": "hold", "level": 5.1}, - {"state": "hold", "level": 5.1}, - {"state": "discharge", "level": 4.9}, + {"state": "grid_charge", "level": 5.1}, + {"state": "self_consume", "level": 5.1}, + {"state": "self_consume", "level": 5.1}, + {"state": "self_consume", "level": 4.9}, ], }, { @@ -163,17 +163,17 @@ def _fake_optimize_with_target_behavior(params: object) -> dict[str, object]: battery = params.battery_entities[0] battery_schedule = ( [ - {"state": "charge_grid", "level": 6.5}, - {"state": "charge_grid", "level": 8.0}, - {"state": "hold", "level": 8.0}, - {"state": "hold", "level": 8.0}, + {"state": "grid_charge", "level": 6.5}, + {"state": "grid_charge", "level": 8.0}, + {"state": "self_consume", "level": 8.0}, + {"state": "self_consume", "level": 8.0}, ] if battery.target is not None else [ - {"state": "hold", "level": 5.0}, - {"state": "hold", "level": 5.0}, - {"state": "hold", "level": 5.0}, - {"state": "hold", "level": 5.0}, + {"state": "self_consume", "level": 5.0}, + {"state": "self_consume", "level": 5.0}, + {"state": "self_consume", "level": 5.0}, + {"state": "self_consume", "level": 5.0}, ] ) return { @@ -437,7 +437,7 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: battery_action = hass.states.get("sensor.home_battery_action") assert battery_action is not None assert battery_action.attributes["friendly_name"] == "(battery) Action" - assert battery_action.state == "charge_grid" + assert battery_action.state == "grid_charge" assert "next_action" not in battery_action.attributes assert "next_action_timestamp" not in battery_action.attributes @@ -559,10 +559,10 @@ async def test_battery_action_sensor_uses_source_specific_charge_state( "name": "battery", "type": "battery", "schedule": [ - {"state": "charge_grid_pv", "level": 5.2}, - {"state": "hold", "level": 5.2}, - {"state": "hold", "level": 5.2}, - {"state": "hold", "level": 5.2}, + {"state": "grid_charge", "level": 5.2}, + {"state": "self_consume", "level": 5.2}, + {"state": "self_consume", "level": 5.2}, + {"state": "self_consume", "level": 5.2}, ], } ], @@ -578,7 +578,7 @@ async def test_battery_action_sensor_uses_source_specific_charge_state( battery_action = hass.states.get("sensor.home_battery_action") assert battery_action is not None - assert battery_action.state == "charge_grid_pv" + assert battery_action.state == "grid_charge" async def test_battery_next_action_sensor_exposes_timestamp_and_state( @@ -648,10 +648,10 @@ async def test_battery_next_action_sensor_exposes_timestamp_and_state( "name": "battery", "type": "battery", "schedule": [ - {"state": "hold", "level": 5.0}, - {"state": "charge_grid_pv", "level": 5.2}, - {"state": "hold", "level": 5.2}, - {"state": "hold", "level": 5.2}, + {"state": "self_consume", "level": 5.0}, + {"state": "grid_charge", "level": 5.2}, + {"state": "self_consume", "level": 5.2}, + {"state": "self_consume", "level": 5.2}, ], } ], @@ -667,7 +667,7 @@ async def test_battery_next_action_sensor_exposes_timestamp_and_state( next_action = hass.states.get("sensor.home_battery_next_action") assert next_action is not None - assert next_action.state == "charge_grid_pv" + assert next_action.state == "grid_charge" assert "timestamp" in next_action.attributes @@ -899,7 +899,7 @@ async def test_plan_details_sensor_exposes_horizon_length_arrays( assert len(state.attributes["battery_battery_level_kwh"]) == 4 assert len(state.attributes["comfort_comfort_enabled"]) == 4 assert len(state.attributes["optional_optional_enabled"]) == 4 - assert state.attributes["battery_battery_action"] == ["c_g", "h", "h", "d"] + assert state.attributes["battery_battery_action"] == ["gc", "sc", "sc", "sc"] assert state.attributes["comfort_comfort_enabled"] == [True, False, False, True] assert state.attributes["optional_optional_enabled"] == [False, True, True, False] assert "timings" not in state.attributes @@ -1133,7 +1133,12 @@ async def test_battery_target_changes_plan_and_expires_after_deadline( plan_details = hass.states.get("sensor.home_plan_details") assert plan_details is not None - assert plan_details.attributes["battery_battery_action"] == ["h", "h", "h", "h"] + assert plan_details.attributes["battery_battery_action"] == [ + "sc", + "sc", + "sc", + "sc", + ] target_at = dt_util.utcnow() + timedelta(hours=2) await hass.services.async_call( @@ -1156,10 +1161,10 @@ async def test_battery_target_changes_plan_and_expires_after_deadline( plan_details = hass.states.get("sensor.home_plan_details") assert plan_details is not None assert plan_details.attributes["battery_battery_action"] == [ - "c_g", - "c_g", - "h", - "h", + "gc", + "gc", + "sc", + "sc", ] expired_at = target_at + timedelta(minutes=1) @@ -1183,10 +1188,10 @@ async def test_battery_target_changes_plan_and_expires_after_deadline( plan_details = hass.states.get("sensor.home_plan_details") assert plan_details is not None assert plan_details.attributes["battery_battery_action"] == [ - "h", - "h", - "h", - "h", + "sc", + "sc", + "sc", + "sc", ] diff --git a/tests/optimizer/test_optimizer_scenarios.py b/tests/optimizer/test_optimizer_scenarios.py index 6395756..0ae970a 100644 --- a/tests/optimizer/test_optimizer_scenarios.py +++ b/tests/optimizer/test_optimizer_scenarios.py @@ -18,6 +18,18 @@ def _run_optimizer(input_payload): return assert_plan_invariants(optimizer.optimize(params)) +def _level_increase_count(schedule, *, initial_level): + """Return how many schedule points increase the battery level.""" + increases = 0 + previous = float(initial_level) + for point in schedule: + current = float(point["level"]) + if current > previous + 1e-6: + increases += 1 + previous = current + return increases + + def _assert_common_result_shape( result, intervals, expected_entities, expect_suboptimal=False ): @@ -107,11 +119,9 @@ def _assert_common_result_shape( assert math.isfinite(float(point["level"])) if entity["type"] == "battery": assert point.get("state") in { - "hold", - "discharge", - "charge_grid", - "charge_pv", - "charge_grid_pv", + "preserve", + "self_consume", + "grid_charge", } else: assert isinstance(point.get("enabled"), bool) @@ -499,45 +509,108 @@ def test_feed_in_prices_shift_pv_charging_to_lower_export_value_slots(): zero_schedule = zero_feed_in["entities"][0]["schedule"] valued_schedule = valued_feed_in["entities"][0]["schedule"] - assert zero_schedule[0]["state"] == "charge_pv" - assert valued_schedule[0]["state"] == "hold" - assert valued_schedule[1]["state"] == "charge_pv" + assert zero_schedule[0]["state"] == "self_consume" + assert valued_schedule[0]["state"] == "self_consume" + assert valued_schedule[1]["state"] == "self_consume" assert valued_feed_in["projections"]["projected_cost"] < ( zero_feed_in["projections"]["projected_cost"] ) -def test_battery_schedule_state_uses_source_specific_names(): +def test_battery_schedule_state_emits_policy_names(): + entity = optimizer.BatteryEntity( + name="battery", + initial_kwh=0.0, + minimum_kwh=0.0, + capacity_kwh=10.0, + target=None, + charge_curve_kwh=[1.0], + discharge_curve_kwh=[1.0], + charge_efficiency=1.0, + discharge_efficiency=1.0, + throughput_cost_per_kwh=0.0, + action_deadband_kwh=0.0, + mode_switch_cost=0.0, + prefer_pv_surplus_charging=False, + can_charge_from=3, + ) result = { "battery_states": optimizer.np.asarray( [[1, 1, 1, 0, 2]], dtype=optimizer.np.float64 ), + "battery_levels": optimizer.np.asarray( + [[0.0, 1.0, 2.0, 3.0, 3.0, 2.0]], dtype=optimizer.np.float64 + ), "battery_charge_grid": optimizer.np.asarray( [[1.0, 0.0, 1.0, 0.0, 0.0]], dtype=optimizer.np.float64 ), "battery_charge_pv": optimizer.np.asarray( [[0.0, 1.0, 1.0, 0.0, 0.0]], dtype=optimizer.np.float64 ), + "battery_discharge": optimizer.np.asarray( + [[0.0, 0.0, 0.0, 0.0, 1.0]], dtype=optimizer.np.float64 + ), + "comfort_enabled": optimizer.np.asarray( + optimizer.np.zeros((0, 5)), dtype=optimizer.np.float64 + ), } - assert optimizer._battery_schedule_state(result, 0, 0) == "charge_grid" - assert optimizer._battery_schedule_state(result, 0, 1) == "charge_pv" - assert optimizer._battery_schedule_state(result, 0, 2) == "charge_grid_pv" - assert optimizer._battery_schedule_state(result, 0, 3) == "hold" - assert optimizer._battery_schedule_state(result, 0, 4) == "discharge" - - -def test_battery_schedule_state_rejects_charge_without_ingress(): + assert optimizer._battery_schedule_state(result, entity, 0, 0) == "grid_charge" + assert optimizer._battery_schedule_state(result, entity, 0, 1) == "self_consume" + assert optimizer._battery_schedule_state(result, entity, 0, 2) == "grid_charge" + assert ( + optimizer._battery_schedule_state( + result, + entity, + 0, + 3, + usage=optimizer.np.asarray([0.0, 0.0, 0.0, 1.0, 0.0]), + solar_input=optimizer.np.asarray([0.0, 0.0, 0.0, 0.0, 0.0]), + comfort_entities=[], + ) + == "preserve" + ) + assert optimizer._battery_schedule_state(result, entity, 0, 4) == "self_consume" + + +def test_battery_schedule_state_defaults_pv_or_neutral_flow_to_self_consume(): + entity = optimizer.BatteryEntity( + name="battery", + initial_kwh=0.0, + minimum_kwh=0.0, + capacity_kwh=10.0, + target=None, + charge_curve_kwh=[1.0], + discharge_curve_kwh=[1.0], + charge_efficiency=1.0, + discharge_efficiency=1.0, + throughput_cost_per_kwh=0.0, + action_deadband_kwh=0.0, + mode_switch_cost=0.0, + prefer_pv_surplus_charging=False, + can_charge_from=2, + ) result = { - "battery_states": optimizer.np.asarray([[1]], dtype=optimizer.np.float64), - "battery_charge_grid": optimizer.np.asarray([[0.0]], dtype=optimizer.np.float64), - "battery_charge_pv": optimizer.np.asarray([[0.0]], dtype=optimizer.np.float64), + "battery_states": optimizer.np.asarray([[1, 0]], dtype=optimizer.np.float64), + "battery_levels": optimizer.np.asarray( + [[0.0, 1.0, 1.0]], dtype=optimizer.np.float64 + ), + "battery_charge_grid": optimizer.np.asarray( + [[0.0, 0.0]], dtype=optimizer.np.float64 + ), + "battery_charge_pv": optimizer.np.asarray( + [[1.0, 0.0]], dtype=optimizer.np.float64 + ), + "battery_discharge": optimizer.np.asarray( + [[0.0, 0.0]], dtype=optimizer.np.float64 + ), + "comfort_enabled": optimizer.np.asarray( + optimizer.np.zeros((0, 2)), dtype=optimizer.np.float64 + ), } - with pytest.raises( - ValueError, match="charging state without charge ingress" - ): - optimizer._battery_schedule_state(result, 0, 0) + assert optimizer._battery_schedule_state(result, entity, 0, 0) == "self_consume" + assert optimizer._battery_schedule_state(result, entity, 0, 1) == "self_consume" def test_live_grid_export_benchmark_scenario_uses_real_15min_stromligning_values(): @@ -807,9 +880,9 @@ def test_live_grid_export_benchmark_scenario_uses_real_15min_stromligning_values without_feed_in["projections"]["projected_cost"] ) assert with_schedule != without_schedule - assert sum( - point["state"].startswith("charge_") for point in with_schedule - ) < sum(point["state"].startswith("charge_") for point in without_schedule) + assert _level_increase_count(with_schedule, initial_level=0.0) < ( + _level_increase_count(without_schedule, initial_level=0.0) + ) def test_validation_rejects_series_length_mismatch(): @@ -1343,7 +1416,7 @@ def test_warm_started_target_uses_early_low_cost_window_before_deadline(): schedule = result["entities"][0]["schedule"] assert result["suboptimal"] is False - assert schedule[0]["state"] == "charge_grid" + assert schedule[0]["state"] == "grid_charge" assert float(schedule[1]["level"]) > float(schedule[0]["level"]) assert float(schedule[2]["level"]) >= 4.5 assert float(schedule[5]["level"]) >= 4.5 @@ -1590,12 +1663,14 @@ def test_conservative_profile_reduces_marginal_arbitrage(): conservative_payload["mode_switch_cost"] = 0.03 conservative = _run_optimizer(conservative_payload) - assert low_cost["entities"][0]["schedule"][0]["state"] == "charge_grid" + assert low_cost["entities"][0]["schedule"][0]["state"] == "grid_charge" assert any( - point["state"] == "discharge" for point in low_cost["entities"][0]["schedule"] + point["state"] == "self_consume" + for point in low_cost["entities"][0]["schedule"] ) assert all( - point["state"] == "hold" for point in conservative["entities"][0]["schedule"] + point["state"] == "self_consume" + for point in conservative["entities"][0]["schedule"] ) @@ -1626,13 +1701,14 @@ def test_conservative_profile_suppresses_tiny_battery_moves(): conservative_payload["mode_switch_cost"] = 0.03 with_profile = _run_optimizer(conservative_payload) - assert no_deadband["entities"][0]["schedule"][0]["state"] == "charge_grid" + assert no_deadband["entities"][0]["schedule"][0]["state"] == "grid_charge" assert any( - point["state"] == "discharge" + point["state"] == "self_consume" for point in no_deadband["entities"][0]["schedule"] ) assert all( - point["state"] == "hold" for point in with_profile["entities"][0]["schedule"] + point["state"] == "self_consume" + for point in with_profile["entities"][0]["schedule"] ) @@ -1661,7 +1737,7 @@ def test_prefer_pv_surplus_charging_sinks_surplus_into_battery(): 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]["state"] == "self_consume" assert baseline["entities"][0]["schedule"][0]["level"] == pytest.approx(0.0) - assert pv_sink["entities"][0]["schedule"][0]["state"] == "charge_pv" + assert pv_sink["entities"][0]["schedule"][0]["state"] == "self_consume" assert pv_sink["entities"][0]["schedule"][0]["level"] == pytest.approx(1.0) From 500872a74ca31b64c107b785e7004026ecd6c0d0 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 22 Apr 2026 13:57:26 +0200 Subject: [PATCH 2/6] Add model-backed battery preserve signal --- .../wattplan/optimizer/models.py | 10 + .../wattplan/optimizer/mpc_power_optimizer.py | 238 ++++++++++-------- docs/extras.md | 2 +- docs/optimizer-api.md | 4 +- tests/optimizer/test_optimizer_scenarios.py | 77 +++++- 5 files changed, 212 insertions(+), 119 deletions(-) diff --git a/custom_components/wattplan/optimizer/models.py b/custom_components/wattplan/optimizer/models.py index e9d9cc3..d1c2098 100644 --- a/custom_components/wattplan/optimizer/models.py +++ b/custom_components/wattplan/optimizer/models.py @@ -538,6 +538,7 @@ class NormalizedState: battery_charge_grid: np.ndarray battery_charge_pv: np.ndarray battery_discharge: np.ndarray + battery_preserve: np.ndarray comfort_on: np.ndarray comfort_lock_mode: np.ndarray comfort_lock_remaining: np.ndarray @@ -638,6 +639,10 @@ def _parse_state_blob(state_blob): battery_charge_grid = np.asarray(obj["battery_charge_grid"], dtype=np.float64) battery_charge_pv = np.asarray(obj["battery_charge_pv"], dtype=np.float64) battery_discharge = np.asarray(obj["battery_discharge"], dtype=np.float64) + battery_preserve = np.asarray( + obj.get("battery_preserve", np.zeros_like(battery_discharge)), + dtype=np.bool_, + ) comfort_on = np.asarray(obj["comfort_on"], dtype=np.float64) comfort_lock_mode = np.asarray(obj["comfort_lock_mode"], dtype=np.float64) comfort_lock_remaining = np.asarray( @@ -657,6 +662,8 @@ def _parse_state_blob(state_blob): battery_charge_grid = battery_charge_grid.reshape(0, num_steps) if battery_charge_pv.ndim == 1 and battery_charge_pv.size == 0: battery_charge_pv = battery_charge_pv.reshape(0, num_steps) + if battery_preserve.ndim == 1 and battery_preserve.size == 0: + battery_preserve = battery_preserve.reshape(0, num_steps) if comfort_on.ndim == 1 and comfort_on.size == 0: comfort_on = comfort_on.reshape(0, num_steps) if comfort_lock_mode.ndim == 1 and comfort_lock_mode.size == 0: @@ -680,6 +687,8 @@ def _parse_state_blob(state_blob): raise ValueError("state.battery_charge_grid shape mismatch") if battery_charge_pv.ndim != 2 or battery_charge_pv.shape[1] != num_steps: raise ValueError("state.battery_charge_pv shape mismatch") + if battery_preserve.ndim != 2 or battery_preserve.shape[1] != num_steps: + raise ValueError("state.battery_preserve shape mismatch") if comfort_on.ndim != 2 or comfort_on.shape[1] != num_steps: raise ValueError("state.comfort_on shape mismatch") if comfort_lock_mode.ndim != 2 or comfort_lock_mode.shape[1] != num_steps: @@ -714,6 +723,7 @@ def _parse_state_blob(state_blob): battery_charge_grid=battery_charge_grid, battery_charge_pv=battery_charge_pv, battery_discharge=battery_discharge, + battery_preserve=battery_preserve, comfort_on=comfort_on, comfort_lock_mode=comfort_lock_mode, comfort_lock_remaining=comfort_lock_remaining, diff --git a/custom_components/wattplan/optimizer/mpc_power_optimizer.py b/custom_components/wattplan/optimizer/mpc_power_optimizer.py index 1633705..7a6667f 100644 --- a/custom_components/wattplan/optimizer/mpc_power_optimizer.py +++ b/custom_components/wattplan/optimizer/mpc_power_optimizer.py @@ -20,6 +20,8 @@ MPC_HORIZON = 22 EPSILON = 1e-6 AVG_PRICE_SENTINEL = 1000.0 +PRESERVE_PROBE_MIN_KWH = 0.01 +PRESERVE_OBJECTIVE_TOLERANCE = 1e-7 def piecewise_value_interpolated(level_array, curve): @@ -125,6 +127,7 @@ def _build_reuse_plan( battery_charge_grid = previous_state.battery_charge_grid battery_charge_pv = previous_state.battery_charge_pv battery_discharge = previous_state.battery_discharge + battery_preserve = previous_state.battery_preserve comfort_on = previous_state.comfort_on comfort_lock_mode = previous_state.comfort_lock_mode comfort_lock_remaining = previous_state.comfort_lock_remaining @@ -137,6 +140,8 @@ def _build_reuse_plan( return None if battery_charge_pv.shape[0] != num_battery: return None + if battery_preserve.shape[0] != num_battery: + return None if comfort_on.shape[0] != num_comfort: return None if comfort_lock_mode.shape[0] != num_comfort: @@ -200,6 +205,9 @@ def _build_reuse_plan( "battery_discharge": battery_discharge[ :, best_offset : best_offset + best_overlap ], + "battery_preserve": battery_preserve[ + :, best_offset : best_offset + best_overlap + ], "comfort_on": comfort_on[:, best_offset : best_offset + best_overlap], "comfort_lock_mode": comfort_lock_mode[ :, best_offset : best_offset + best_overlap @@ -300,9 +308,10 @@ def _solve_lp(objective, A_ub, b_ub, A_eq, b_eq, bounds, integrality=None): model_status = highs.getModelStatus() class _HighspyResult: - def __init__(self, success, x): + def __init__(self, success, x, objective_value=None): self.success = success self.x = x + self.objective_value = objective_value if model_status != highspy.HighsModelStatus.kOptimal: return _HighspyResult(False, None) @@ -314,6 +323,7 @@ def __init__(self, success, x): return _HighspyResult( True, np.asarray(solution.col_value, dtype=np.float64), + float(highs.getObjectiveValue()), ) @@ -341,6 +351,7 @@ def _solve_mpc_step( prev_comfort_on, comfort_off_streaks_now, remaining_steps_total, + forced_discharge_first=None, ): horizon = len(prices_h) num_battery = len(battery_entities) @@ -480,6 +491,17 @@ def _solve_mpc_step( A_ub.append(row) b_ub.append(float(target["upper_kwh"])) + forced_discharge = ( + 0.0 + if forced_discharge_first is None + else float(forced_discharge_first.get(b, 0.0)) + ) + if forced_discharge > EPSILON and horizon > 0: + row = np.zeros(n_vars, dtype=np.float64) + row[var["discharge"].start] = -1.0 + A_ub.append(row) + b_ub.append(-forced_discharge) + for c, entity in enumerate(comfort_entities): var = comfort_vars[c] max_off = int(entity.max_consecutive_off_slots) @@ -620,9 +642,49 @@ def _solve_mpc_step( "charge_pv": charge_pv_cmd, "discharge": discharge_cmd, "comfort_on": comfort_cmd, + "objective_value": float(result.objective_value), } +def _battery_available_discharge_kwh(entity: BatteryEntity, level: float) -> float: + discharge_eff = float(entity.discharge_efficiency) + return max(float(level) - float(entity.minimum_kwh), 0.0) * discharge_eff + + +def _battery_preserve_probe_kwh(entity: BatteryEntity, level: float) -> float: + _, discharge_limit = _battery_power_limits(entity, level) + available = _battery_available_discharge_kwh(entity, level) + action_deadband = max(float(entity.action_deadband_kwh), EPSILON) + requested_probe = max(action_deadband, PRESERVE_PROBE_MIN_KWH) + return min(available, discharge_limit, requested_probe) + + +def _slot_uncovered_model_load_kwh( + timeslot: int, + *, + usage, + solar_input, + comfort_entities, + comfort_cmd, +) -> float: + comfort_load = 0.0 + for i, comfort in enumerate(comfort_entities): + if float(comfort_cmd[i]) >= 0.5: + comfort_load += float(comfort.power_usage_kwh) + return max( + float(usage[timeslot]) + comfort_load - float(solar_input[timeslot]), + 0.0, + ) + + +def _objective_is_worse(counterfactual_objective, base_objective) -> bool: + tolerance = max( + PRESERVE_OBJECTIVE_TOLERANCE, + abs(float(base_objective)) * 1e-9, + ) + return float(counterfactual_objective) > float(base_objective) + tolerance + + def _apply_controls_step( t, controls, @@ -825,6 +887,7 @@ def _run_mpc( battery_charge_grid = np.zeros((num_battery, total_steps), dtype=np.float64) battery_charge_pv = np.zeros((num_battery, total_steps), dtype=np.float64) battery_discharge = np.zeros((num_battery, total_steps), dtype=np.float64) + battery_preserve = np.zeros((num_battery, total_steps), dtype=np.bool_) grid_export = np.zeros(total_steps, dtype=np.float64) comfort_on = np.zeros((num_comfort, total_steps), dtype=np.float64) comfort_lock_mode_series = np.zeros((num_comfort, total_steps), dtype=np.float64) @@ -873,6 +936,9 @@ def _run_mpc( "discharge": reuse_plan["battery_discharge"][:, t].copy(), "comfort_on": reuse_plan["comfort_on"][:, t].copy(), } + battery_preserve[:, t] = reuse_plan["battery_preserve"][:, t].astype( + np.bool_ + ) if num_comfort > 0: comfort_lock_mode = reuse_plan["comfort_lock_mode"][:, t].astype( np.int32 @@ -949,6 +1015,70 @@ def _run_mpc( "discharge": solve_result["discharge"], "comfort_on": comfort_cmd, } + uncovered_load = _slot_uncovered_model_load_kwh( + t, + usage=usage, + solar_input=solar_input, + comfort_entities=comfort_entities, + comfort_cmd=comfort_cmd, + ) + for b, entity in enumerate(battery_entities): + action_deadband = max(float(entity.action_deadband_kwh), EPSILON) + if uncovered_load <= action_deadband: + continue + if float(solve_result["charge_grid"][b]) > action_deadband: + continue + if float(solve_result["discharge"][b]) > action_deadband: + continue + + available = _battery_available_discharge_kwh( + entity, float(battery_levels[b, t]) + ) + if ( + float(entity.minimum_kwh) > EPSILON + and available <= action_deadband + ): + battery_preserve[b, t] = True + continue + + probe_kwh = min( + _battery_preserve_probe_kwh(entity, float(battery_levels[b, t])), + uncovered_load, + ) + if probe_kwh <= action_deadband: + continue + + counterfactual = _solve_mpc_step( + base_timeslot=t, + prices_h=prices[t : t + horizon], + grid_export_prices_h=grid_export_prices[t : t + horizon], + usage_h=usage_h, + solar_h=solar_input[t : t + horizon], + 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), + prev_comfort_on=prev_comfort_on[unlocked_indices] + if unlocked_indices + else np.zeros(0, dtype=np.float64), + comfort_off_streaks_now=comfort_off_streaks[unlocked_indices, t] + if unlocked_indices + else np.zeros(0, dtype=np.float64), + remaining_steps_total=total_steps - t, + forced_discharge_first={b: probe_kwh}, + ) + if counterfactual is None or _objective_is_worse( + counterfactual["objective_value"], + solve_result["objective_value"], + ): + battery_preserve[b, t] = True if num_comfort > 0: comfort_lock_mode_series[:, t] = comfort_lock_mode.astype(np.float64) @@ -1018,6 +1148,7 @@ def _run_mpc( "battery_charge_grid": battery_charge_grid, "battery_charge_pv": battery_charge_pv, "battery_discharge": battery_discharge, + "battery_preserve": battery_preserve, "grid_export": grid_export, "comfort_on": comfort_on, "comfort_lock_mode": comfort_lock_mode_series, @@ -1225,115 +1356,16 @@ def _battery_schedule_state( entity: BatteryEntity, battery_index: int, timeslot: int, - *, - usage=None, - solar_input=None, - comfort_entities=None, ) -> str: """Return the serialized battery policy state for one schedule slot.""" action_deadband = max(float(entity.action_deadband_kwh), EPSILON) if result["battery_charge_grid"][battery_index, timeslot] > action_deadband: return "grid_charge" - if _battery_should_preserve( - result, - entity, - battery_index, - timeslot, - usage=usage, - solar_input=solar_input, - comfort_entities=comfort_entities, - action_deadband=action_deadband, - ): + if bool(result["battery_preserve"][battery_index, timeslot]): return "preserve" return "self_consume" -def _battery_should_preserve( - result, - entity: BatteryEntity, - battery_index: int, - timeslot: int, - *, - usage, - solar_input, - comfort_entities, - action_deadband: float, -) -> bool: - """Return True when the model gives a positive reason to block discharge. - - The optimizer does not currently expose a shadow price for stored battery - energy, so preserve is intentionally conservative. We only emit it for a - target/minimum constraint or when the plan faces load now, chooses not to - discharge this battery, and schedules that same battery to discharge later. - Neutral/PV-only slots stay self_consume so unexpected real load can still be - served by the inverter. - """ - battery_state = int(result["battery_states"][battery_index, timeslot]) - if battery_state == 2: - return False - - level = float(result["battery_levels"][battery_index, timeslot]) - minimum_kwh = float(entity.minimum_kwh) - discharge_eff = float(entity.discharge_efficiency) - available_for_discharge = max(level - minimum_kwh, 0.0) * discharge_eff - - if entity.target is not None and timeslot <= int(entity.target.timeslot): - target_floor = float(entity.target.soc_kwh) - float(entity.target.tolerance_kwh) - if ( - entity.target.mode in {"at_least", "exact"} - and level <= target_floor + action_deadband - ): - return True - - if minimum_kwh > EPSILON and available_for_discharge <= action_deadband: - return _slot_has_model_load( - result, - timeslot, - usage=usage, - solar_input=solar_input, - comfort_entities=comfort_entities, - action_deadband=action_deadband, - ) - - future_discharge = float( - np.sum(result["battery_discharge"][battery_index, timeslot + 1 :]) - ) - if future_discharge <= action_deadband: - return False - if result["battery_discharge"][battery_index, timeslot] > action_deadband: - return False - return _slot_has_model_load( - result, - timeslot, - usage=usage, - solar_input=solar_input, - comfort_entities=comfort_entities, - action_deadband=action_deadband, - ) - - -def _slot_has_model_load( - result, - timeslot: int, - *, - usage, - solar_input, - comfort_entities, - action_deadband: float, -) -> bool: - """Return True when the modeled site has non-PV-covered load this slot.""" - if usage is None or solar_input is None: - return False - comfort_load = 0.0 - for i, comfort in enumerate(comfort_entities or []): - if result["comfort_enabled"][i, timeslot]: - comfort_load += float(comfort.power_usage_kwh) - modeled_load = ( - float(usage[timeslot]) + comfort_load - float(solar_input[timeslot]) - ) - return modeled_load > action_deadband - - def optimize_internal(normalized: CalculationInput): total_steps = normalized.total_steps grid_import_prices = normalized.grid_import_prices @@ -1432,9 +1464,6 @@ def optimize_internal(normalized: CalculationInput): entity, i, t, - usage=usage, - solar_input=solar_input, - comfort_entities=comfort_entities, ), "level": float(result["battery_levels"][i, t + 1]), } @@ -1490,6 +1519,7 @@ def optimize_internal(normalized: CalculationInput): "battery_charge_grid": result["battery_charge_grid"].tolist(), "battery_charge_pv": result["battery_charge_pv"].tolist(), "battery_discharge": result["battery_discharge"].tolist(), + "battery_preserve": result["battery_preserve"].astype(bool).tolist(), "comfort_on": result["comfort_on"].tolist(), "comfort_lock_mode": result["comfort_lock_mode"].tolist(), "comfort_lock_remaining": result["comfort_lock_remaining"].tolist(), diff --git a/docs/extras.md b/docs/extras.md index d47f5eb..3dd9938 100644 --- a/docs/extras.md +++ b/docs/extras.md @@ -55,7 +55,7 @@ The battery action sensor exposes policy, not raw measured or forecast battery f | Policy | Meaning | | --- | --- | -| `preserve` | Save stored energy for future value or target constraints. Your automation should prevent this battery from discharging. PV charging may still be allowed by your inverter setup. | +| `preserve` | Save stored energy because the model shows that spending it now would make the plan worse or violate constraints. Your automation should prevent this battery from discharging. PV charging may still be allowed by your inverter setup. | | `self_consume` | Normal battery operation. Allow this battery to cover real load. Do not request grid charging. This is the default policy when the plan has no positive reason to preserve or grid-charge. | | `grid_charge` | Request or allow grid charging for this battery and prevent the battery from being spent while doing so. | diff --git a/docs/optimizer-api.md b/docs/optimizer-api.md index 34ebe95..11fdfc9 100644 --- a/docs/optimizer-api.md +++ b/docs/optimizer-api.md @@ -217,13 +217,13 @@ Battery schedule `state` values are inverter-control policies derived from the p | State | Meaning | | --- | --- | -| `preserve` | Prevent this battery from discharging. This saves stored energy for target/minimum constraints or modeled future value. PV charging may still be allowed by the user's inverter setup. | +| `preserve` | Prevent this battery from discharging. This saves stored energy when the optimizer shows that spending it now would make the plan worse or violate modeled constraints. PV charging may still be allowed by the user's inverter setup. | | `self_consume` | Normal battery operation. Allow this battery to cover real load. Do not request grid charging. This is also the default when the model has no positive reason to preserve or grid-charge. | | `grid_charge` | Request or allow grid charging for this battery and prevent the battery from being spent while doing so. | PV surplus charging is implicit/normal battery behavior, not a primary action state. PV export is site-level and multi-battery-sensitive, so a dedicated PV export policy is deferred to a future site-level design. -The current preserve inference is intentionally conservative because the optimizer does not expose a shadow price for stored battery energy. `grid_charge` is emitted when modeled grid charging for that battery is above the action deadband. `preserve` is emitted only when a target/minimum constraint or future scheduled discharge gives a concrete model-backed reason to block discharge. Otherwise WattPlan emits `self_consume`, including slots with forecast zero battery flow. +`grid_charge` is emitted when modeled grid charging for that battery is above the action deadband. `preserve` is emitted from a model-backed counterfactual check: when the slot has non-PV-covered modeled load and the optimizer chooses not to discharge a battery, WattPlan asks the same model whether forcing a small discharge from that battery now would be infeasible or make the objective worse. If so, the slot is marked `preserve`; otherwise WattPlan emits `self_consume`. Forecast zero battery flow is not a preserve reason by itself. ### Notes on `entities` and `optional_entity_options` - `entities` is the actual optimized schedule. diff --git a/tests/optimizer/test_optimizer_scenarios.py b/tests/optimizer/test_optimizer_scenarios.py index 0ae970a..9e4f513 100644 --- a/tests/optimizer/test_optimizer_scenarios.py +++ b/tests/optimizer/test_optimizer_scenarios.py @@ -550,6 +550,9 @@ def test_battery_schedule_state_emits_policy_names(): "battery_discharge": optimizer.np.asarray( [[0.0, 0.0, 0.0, 0.0, 1.0]], dtype=optimizer.np.float64 ), + "battery_preserve": optimizer.np.asarray( + [[False, False, True, True, False]], dtype=optimizer.np.bool_ + ), "comfort_enabled": optimizer.np.asarray( optimizer.np.zeros((0, 5)), dtype=optimizer.np.float64 ), @@ -558,18 +561,7 @@ def test_battery_schedule_state_emits_policy_names(): assert optimizer._battery_schedule_state(result, entity, 0, 0) == "grid_charge" assert optimizer._battery_schedule_state(result, entity, 0, 1) == "self_consume" assert optimizer._battery_schedule_state(result, entity, 0, 2) == "grid_charge" - assert ( - optimizer._battery_schedule_state( - result, - entity, - 0, - 3, - usage=optimizer.np.asarray([0.0, 0.0, 0.0, 1.0, 0.0]), - solar_input=optimizer.np.asarray([0.0, 0.0, 0.0, 0.0, 0.0]), - comfort_entities=[], - ) - == "preserve" - ) + assert optimizer._battery_schedule_state(result, entity, 0, 3) == "preserve" assert optimizer._battery_schedule_state(result, entity, 0, 4) == "self_consume" @@ -604,6 +596,9 @@ def test_battery_schedule_state_defaults_pv_or_neutral_flow_to_self_consume(): "battery_discharge": optimizer.np.asarray( [[0.0, 0.0]], dtype=optimizer.np.float64 ), + "battery_preserve": optimizer.np.asarray( + [[False, False]], dtype=optimizer.np.bool_ + ), "comfort_enabled": optimizer.np.asarray( optimizer.np.zeros((0, 2)), dtype=optimizer.np.float64 ), @@ -613,6 +608,64 @@ def test_battery_schedule_state_defaults_pv_or_neutral_flow_to_self_consume(): assert optimizer._battery_schedule_state(result, entity, 0, 1) == "self_consume" +def test_model_marks_preserve_when_forced_discharge_now_is_more_expensive(): + payload = { + "grid_import_price_per_kwh": [0.10, 1.00, 1.00, 1.00], + "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": [1.0, 1.0, 1.0, 1.0], + "battery_entities": [ + { + "name": "battery", + "initial_kwh": 1.0, + "minimum_kwh": 0.0, + "capacity_kwh": 1.0, + "charge_curve_kwh": [0.0], + "discharge_curve_kwh": [1.0], + "can_charge_from": 0, + } + ], + "comfort_entities": [], + } + + result = _run_optimizer(payload) + schedule = result["entities"][0]["schedule"] + + assert schedule[0]["state"] == "preserve" + assert schedule[0]["level"] == pytest.approx(1.0) + assert schedule[1]["state"] == "self_consume" + assert schedule[1]["level"] == pytest.approx(0.0) + + +def test_battery_target_does_not_create_preserve_policy_by_itself(): + payload = { + "grid_import_price_per_kwh": [0.10, 0.10, 0.10, 0.10], + "grid_export_price_per_kwh": [0.0, 0.0, 0.0, 0.0], + "solar_input_kwh": [1.0, 1.0, 1.0, 1.0], + "usage_kwh": [0.0, 0.0, 0.0, 0.0], + "battery_entities": [ + { + "name": "battery", + "initial_kwh": 0.5, + "minimum_kwh": 0.0, + "capacity_kwh": 1.0, + "target": {"timeslot": 3, "soc_kwh": 0.5}, + "charge_curve_kwh": [1.0], + "discharge_curve_kwh": [1.0], + "can_charge_from": 2, + } + ], + "comfort_entities": [], + } + + result = _run_optimizer(payload) + + assert all( + point["state"] == "self_consume" + for point in result["entities"][0]["schedule"] + ) + + def test_live_grid_export_benchmark_scenario_uses_real_15min_stromligning_values(): # Live Home Assistant data captured on 2026-03-09 in Europe/Copenhagen. # Strømligning is native 15-minute price data. Deye daily energy totals are From 171cd9555f361d767d5b979c7d5fe5d314676acf Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 22 Apr 2026 14:59:04 +0200 Subject: [PATCH 3/6] Refine battery preserve policy inference --- .../wattplan/optimizer/mpc_power_optimizer.py | 35 +-- docs/optimizer-api.md | 2 +- tests/optimizer/test_optimizer_scenarios.py | 212 ++++++++++++++++++ 3 files changed, 233 insertions(+), 16 deletions(-) diff --git a/custom_components/wattplan/optimizer/mpc_power_optimizer.py b/custom_components/wattplan/optimizer/mpc_power_optimizer.py index 7a6667f..05aaaef 100644 --- a/custom_components/wattplan/optimizer/mpc_power_optimizer.py +++ b/custom_components/wattplan/optimizer/mpc_power_optimizer.py @@ -659,11 +659,10 @@ def _battery_preserve_probe_kwh(entity: BatteryEntity, level: float) -> float: return min(available, discharge_limit, requested_probe) -def _slot_uncovered_model_load_kwh( +def _slot_modeled_load_kwh( timeslot: int, *, usage, - solar_input, comfort_entities, comfort_cmd, ) -> float: @@ -671,10 +670,7 @@ def _slot_uncovered_model_load_kwh( for i, comfort in enumerate(comfort_entities): if float(comfort_cmd[i]) >= 0.5: comfort_load += float(comfort.power_usage_kwh) - return max( - float(usage[timeslot]) + comfort_load - float(solar_input[timeslot]), - 0.0, - ) + return float(usage[timeslot]) + comfort_load def _objective_is_worse(counterfactual_objective, base_objective) -> bool: @@ -1015,17 +1011,15 @@ def _run_mpc( "discharge": solve_result["discharge"], "comfort_on": comfort_cmd, } - uncovered_load = _slot_uncovered_model_load_kwh( + modeled_load = _slot_modeled_load_kwh( t, usage=usage, - solar_input=solar_input, comfort_entities=comfort_entities, comfort_cmd=comfort_cmd, ) + pv_surplus = max(float(solar_input[t]) - modeled_load, 0.0) for b, entity in enumerate(battery_entities): action_deadband = max(float(entity.action_deadband_kwh), EPSILON) - if uncovered_load <= action_deadband: - continue if float(solve_result["charge_grid"][b]) > action_deadband: continue if float(solve_result["discharge"][b]) > action_deadband: @@ -1041,18 +1035,29 @@ def _run_mpc( battery_preserve[b, t] = True continue - probe_kwh = min( - _battery_preserve_probe_kwh(entity, float(battery_levels[b, t])), - uncovered_load, + probe_kwh = _battery_preserve_probe_kwh( + entity, float(battery_levels[b, t]) ) if probe_kwh <= action_deadband: continue + # A preserve policy is about unexpected real load, not the + # forecast load already in the plan. If PV surplus exists, the + # marginal load would consume that PV first. Only the next + # probe-sized slice asks whether spending battery is worse than + # importing and preserving it for later value. + counterfactual_usage_h = usage_h.copy() + counterfactual_usage_h[0] += pv_surplus + probe_kwh + preserve_baseline_objective = ( + float(solve_result["objective_value"]) + + float(grid_export_prices[t]) * pv_surplus + + float(prices[t]) * probe_kwh + ) counterfactual = _solve_mpc_step( base_timeslot=t, prices_h=prices[t : t + horizon], grid_export_prices_h=grid_export_prices[t : t + horizon], - usage_h=usage_h, + usage_h=counterfactual_usage_h, solar_h=solar_input[t : t + horizon], battery_entities=battery_entities, comfort_entities=[comfort_entities[i] for i in unlocked_indices], @@ -1076,7 +1081,7 @@ def _run_mpc( ) if counterfactual is None or _objective_is_worse( counterfactual["objective_value"], - solve_result["objective_value"], + preserve_baseline_objective, ): battery_preserve[b, t] = True diff --git a/docs/optimizer-api.md b/docs/optimizer-api.md index 11fdfc9..362bd5d 100644 --- a/docs/optimizer-api.md +++ b/docs/optimizer-api.md @@ -223,7 +223,7 @@ Battery schedule `state` values are inverter-control policies derived from the p PV surplus charging is implicit/normal battery behavior, not a primary action state. PV export is site-level and multi-battery-sensitive, so a dedicated PV export policy is deferred to a future site-level design. -`grid_charge` is emitted when modeled grid charging for that battery is above the action deadband. `preserve` is emitted from a model-backed counterfactual check: when the slot has non-PV-covered modeled load and the optimizer chooses not to discharge a battery, WattPlan asks the same model whether forcing a small discharge from that battery now would be infeasible or make the objective worse. If so, the slot is marked `preserve`; otherwise WattPlan emits `self_consume`. Forecast zero battery flow is not a preserve reason by itself. +`grid_charge` is emitted when modeled grid charging for that battery is above the action deadband. `preserve` is emitted from a model-backed counterfactual check: when 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. ### Notes on `entities` and `optional_entity_options` - `entities` is the actual optimized schedule. diff --git a/tests/optimizer/test_optimizer_scenarios.py b/tests/optimizer/test_optimizer_scenarios.py index 9e4f513..24b3ba2 100644 --- a/tests/optimizer/test_optimizer_scenarios.py +++ b/tests/optimizer/test_optimizer_scenarios.py @@ -637,6 +637,35 @@ def test_model_marks_preserve_when_forced_discharge_now_is_more_expensive(): assert schedule[1]["level"] == pytest.approx(0.0) +def test_model_marks_preserve_for_marginal_load_after_pv_surplus(): + payload = { + "grid_import_price_per_kwh": [0.10, 1.00, 1.00, 1.00], + "grid_export_price_per_kwh": [0.0, 0.0, 0.0, 0.0], + "solar_input_kwh": [1.2, 0.0, 0.0, 0.0], + "usage_kwh": [1.0, 1.0, 1.0, 1.0], + "battery_entities": [ + { + "name": "battery", + "initial_kwh": 1.0, + "minimum_kwh": 0.0, + "capacity_kwh": 1.0, + "charge_curve_kwh": [0.0], + "discharge_curve_kwh": [1.0], + "can_charge_from": 0, + } + ], + "comfort_entities": [], + } + + result = _run_optimizer(payload) + schedule = result["entities"][0]["schedule"] + + assert schedule[0]["state"] == "preserve" + assert schedule[0]["level"] == pytest.approx(1.0) + assert schedule[1]["state"] == "self_consume" + assert schedule[1]["level"] == pytest.approx(0.0) + + def test_battery_target_does_not_create_preserve_policy_by_itself(): payload = { "grid_import_price_per_kwh": [0.10, 0.10, 0.10, 0.10], @@ -666,6 +695,189 @@ def test_battery_target_does_not_create_preserve_policy_by_itself(): ) +def test_live_exported_deye_forecast_self_consumes_until_evening_discharge(): + # Exported from wattplan.export_planner_input on 2026-04-22 for the live + # Home Assistant setup. Although the battery remains full until the evening + # price rise, the model says self-consume is still the correct policy: early + # marginal load is covered by PV surplus or can be recovered before the + # high-value discharge window. + payload = { + "grid_import_price_per_kwh": [ + 0.577338, + 0.573134, + 0.57986, + 0.579954, + 0.57958, + 0.580234, + 0.580421, + 0.589296, + 0.623019, + 0.935472, + 0.945374, + 0.947523, + 1.086435, + 0.961442, + 1.174247, + 1.180226, + 1.529327, + 1.31017, + 1.381634, + 1.620503, + 1.263274, + 1.766701, + 1.358, + 1.180226, + 1.160048, + 1.337009, + 0.861701, + 0.861608, + 0.692055, + 0.846287, + 0.674306, + 0.66132, + ], + "grid_export_price_per_kwh": [ + -0.002989, + -0.007193, + -0.000467, + -0.000374, + -0.000747, + -0.000093, + 0.000093, + 0.008968, + 0.042692, + 0.03662, + 0.046522, + 0.048671, + 0.187582, + 0.06259, + 0.275395, + 0.281374, + 0.630475, + 0.411317, + 0.482782, + 0.72165, + 0.364422, + 0.867849, + 0.459147, + 0.281374, + 0.261195, + 0.756682, + 0.281374, + 0.28128, + 0.111727, + 0.26596, + 0.093978, + 0.080993, + ], + "solar_input_kwh": [ + 4.749, + 2.2255, + 2.2255, + 2.0435, + 2.0435, + 1.8395, + 1.8395, + 1.5905, + 1.5905, + 1.313, + 1.313, + 1.0025, + 1.0025, + 0.71, + 0.71, + 0.3945, + 0.3945, + 0.1235, + 0.1235, + 0.032, + 0.032, + 0.0095, + 0.0095, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + "usage_kwh": [ + 0.2169704598561457, + 0.24453594597439476, + 0.23539831744649636, + 0.23312404106022935, + 0.2298197857041873, + 0.20621877304580288, + 0.2215179470598735, + 0.2548262595346826, + 0.25221516533968485, + 0.30891192422506575, + 0.25971747314094706, + 0.30657993534027406, + 0.3442407494160655, + 0.34868868240295475, + 0.2307738647202011, + 0.1994668734929886, + 0.19826671285536, + 0.2305931644980437, + 0.21648351868157095, + 0.20391384014856817, + 0.19844142051051475, + 0.24931453994647473, + 0.19962354065757215, + 0.19413691567983707, + 0.20074486184872037, + 0.20200322537291235, + 0.25446272213855586, + 0.20780401659467665, + 0.2270879529659598, + 0.3451252775276283, + 0.2691395209923915, + 0.23195316573769167, + ], + "rolling_window_slots": 24, + "throughput_cost_per_kwh": 0.02, + "action_deadband_kwh": 0.05, + "mode_switch_cost": 0.01, + "battery_entities": [ + { + "name": "Battery", + "initial_kwh": 10.0, + "minimum_kwh": 1.0, + "capacity_kwh": 10.0, + "charge_efficiency": 0.9, + "discharge_efficiency": 0.9, + "charge_curve_kwh": [1.25], + "discharge_curve_kwh": [1.25], + "can_charge_from": 3, + "prefer_pv_surplus_charging": True, + } + ], + "comfort_entities": [], + "optional_entities": [ + { + "name": "Hvidevarer", + "duration_timeslots": 8, + "start_after_timeslot": 0, + "start_before_timeslot": 32, + "energy_kwh": 2.0, + "options": 2, + "min_option_gap_timeslots": 4, + } + ], + } + + result = _run_optimizer(payload) + schedule = result["entities"][0]["schedule"] + + assert {point["state"] for point in schedule} == {"self_consume"} + assert all(point["level"] == pytest.approx(10.0) for point in schedule[:17]) + assert schedule[17]["level"] < 10.0 + + def test_live_grid_export_benchmark_scenario_uses_real_15min_stromligning_values(): # Live Home Assistant data captured on 2026-03-09 in Europe/Copenhagen. # Strømligning is native 15-minute price data. Deye daily energy totals are From 8670b15aee352251a8af5009d1a6b61eee6383c3 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 22 Apr 2026 15:17:53 +0200 Subject: [PATCH 4/6] Address battery policy PR feedback --- README.md | 4 +- .../wattplan/optimizer/models.py | 23 ++- .../wattplan/optimizer/mpc_power_optimizer.py | 158 +++++++++--------- docs/entities-and-services.md | 2 +- docs/extras.md | 69 +++++++- docs/optimizer-api.md | 5 +- tests/optimizer/test_optimizer_scenarios.py | 28 ++++ 7 files changed, 207 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 4a9fff2..d0b2ce0 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This documentation is intended for Home Assistant users, energy enthusiasts, and 8. Optionally configure a PV source if you have solar and want WattPlan to plan around it. 9. Optionally configure an export price source if you have PV and want exported power to carry a value instead of defaulting to zero. 10. Add [batteries, comfort loads, or optional loads](docs/extras.md) if you want WattPlan to control more than just forecasting. -11. Make automations to apply the WattPlan actions, to your devices - such as setting batteries to charge, or starting your HVAC +11. Make automations to apply the WattPlan actions to your devices, such as setting batteries to charge or starting your HVAC. See the [real-life examples](docs/extras.md#real-life-examples) for one battery policy automation pattern. ## Configuration Steps After installing WattPlan via HACS, configure the following: @@ -47,7 +47,7 @@ After installing WattPlan via HACS, configure the following: ## Documentation - [docs/source-data.md](docs/source-data.md) - Source modes, data model, and how to feed WattPlan price, export price, usage, and PV data - [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/extras.md](docs/extras.md) - Batteries, comfort loads, optional loads, real-life examples, 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 diff --git a/custom_components/wattplan/optimizer/models.py b/custom_components/wattplan/optimizer/models.py index d1c2098..cd4d35a 100644 --- a/custom_components/wattplan/optimizer/models.py +++ b/custom_components/wattplan/optimizer/models.py @@ -311,6 +311,14 @@ class OptimizationParams(BaseModel): mode_switch_cost: float = Field( 0.0, description="Cost for switching between modeled charge/idle/discharge flow." ) + infer_battery_preserve_policy: bool = Field( + True, + description=( + "When true, run the model-backed counterfactual used to emit battery " + "preserve policy states. When false, battery_preserve output flags " + "are always false." + ), + ) battery_entities: List[BatteryEntityParams] = Field( ..., description="List of battery-like entities." ) @@ -552,6 +560,7 @@ class CalculationInput: solar_input: np.ndarray usage: np.ndarray rolling_window_slots: int + infer_battery_preserve_policy: bool battery_entities: List[BatteryEntity] comfort_entities: List[ComfortEntity] optional_entities: List[NormalizedOptionalEntity] @@ -559,7 +568,12 @@ class CalculationInput: fingerprint: str -def _entity_fingerprint(battery_entities, comfort_entities, rolling_window_slots): +def _entity_fingerprint( + battery_entities, + comfort_entities, + rolling_window_slots, + infer_battery_preserve_policy, +): # Reuse must only happen when the optimization problem is materially the # same. Battery targets change feasible early-slot decisions, so they must # participate in the fingerprint used to accept a previous state blob. @@ -607,6 +621,7 @@ def _entity_fingerprint(battery_entities, comfort_entities, rolling_window_slots for e in comfort_entities ], "rolling_window_slots": int(rolling_window_slots), + "infer_battery_preserve_policy": bool(infer_battery_preserve_policy), } raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") return hashlib.sha256(raw).hexdigest() @@ -812,7 +827,10 @@ def normalize_calculation_input(params: OptimizationParams): ) fingerprint = _entity_fingerprint( - battery_entities, comfort_entities, params.rolling_window_slots + battery_entities, + comfort_entities, + params.rolling_window_slots, + params.infer_battery_preserve_policy, ) state = _parse_state_blob(params.state) @@ -823,6 +841,7 @@ def normalize_calculation_input(params: OptimizationParams): solar_input=solar_input, usage=usage, rolling_window_slots=int(params.rolling_window_slots), + infer_battery_preserve_policy=bool(params.infer_battery_preserve_policy), battery_entities=battery_entities, comfort_entities=comfort_entities, optional_entities=optional_entities, diff --git a/custom_components/wattplan/optimizer/mpc_power_optimizer.py b/custom_components/wattplan/optimizer/mpc_power_optimizer.py index 05aaaef..32a06ad 100644 --- a/custom_components/wattplan/optimizer/mpc_power_optimizer.py +++ b/custom_components/wattplan/optimizer/mpc_power_optimizer.py @@ -869,6 +869,7 @@ def _run_mpc( battery_entities, comfort_entities, reuse_plan, + infer_battery_preserve_policy, ): num_battery = len(battery_entities) num_comfort = len(comfort_entities) @@ -932,9 +933,10 @@ def _run_mpc( "discharge": reuse_plan["battery_discharge"][:, t].copy(), "comfort_on": reuse_plan["comfort_on"][:, t].copy(), } - battery_preserve[:, t] = reuse_plan["battery_preserve"][:, t].astype( - np.bool_ - ) + if infer_battery_preserve_policy: + battery_preserve[:, t] = reuse_plan["battery_preserve"][:, t].astype( + np.bool_ + ) if num_comfort > 0: comfort_lock_mode = reuse_plan["comfort_lock_mode"][:, t].astype( np.int32 @@ -1011,79 +1013,84 @@ def _run_mpc( "discharge": solve_result["discharge"], "comfort_on": comfort_cmd, } - modeled_load = _slot_modeled_load_kwh( - t, - usage=usage, - comfort_entities=comfort_entities, - comfort_cmd=comfort_cmd, - ) - pv_surplus = max(float(solar_input[t]) - modeled_load, 0.0) - for b, entity in enumerate(battery_entities): - action_deadband = max(float(entity.action_deadband_kwh), EPSILON) - if float(solve_result["charge_grid"][b]) > action_deadband: - continue - if float(solve_result["discharge"][b]) > action_deadband: - continue - - available = _battery_available_discharge_kwh( - entity, float(battery_levels[b, t]) - ) - if ( - float(entity.minimum_kwh) > EPSILON - and available <= action_deadband - ): - battery_preserve[b, t] = True - continue - - probe_kwh = _battery_preserve_probe_kwh( - entity, float(battery_levels[b, t]) - ) - if probe_kwh <= action_deadband: - continue - - # A preserve policy is about unexpected real load, not the - # forecast load already in the plan. If PV surplus exists, the - # marginal load would consume that PV first. Only the next - # probe-sized slice asks whether spending battery is worse than - # importing and preserving it for later value. - counterfactual_usage_h = usage_h.copy() - counterfactual_usage_h[0] += pv_surplus + probe_kwh - preserve_baseline_objective = ( - float(solve_result["objective_value"]) - + float(grid_export_prices[t]) * pv_surplus - + float(prices[t]) * probe_kwh - ) - counterfactual = _solve_mpc_step( - base_timeslot=t, - prices_h=prices[t : t + horizon], - grid_export_prices_h=grid_export_prices[t : t + horizon], - usage_h=counterfactual_usage_h, - solar_h=solar_input[t : t + horizon], - 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), - prev_comfort_on=prev_comfort_on[unlocked_indices] - if unlocked_indices - else np.zeros(0, dtype=np.float64), - comfort_off_streaks_now=comfort_off_streaks[unlocked_indices, t] - if unlocked_indices - else np.zeros(0, dtype=np.float64), - remaining_steps_total=total_steps - t, - forced_discharge_first={b: probe_kwh}, + if infer_battery_preserve_policy: + modeled_load = _slot_modeled_load_kwh( + t, + usage=usage, + comfort_entities=comfort_entities, + comfort_cmd=comfort_cmd, ) - if counterfactual is None or _objective_is_worse( - counterfactual["objective_value"], - preserve_baseline_objective, - ): - battery_preserve[b, t] = True + pv_surplus = max(float(solar_input[t]) - modeled_load, 0.0) + for b, entity in enumerate(battery_entities): + action_deadband = max(float(entity.action_deadband_kwh), EPSILON) + if float(solve_result["charge_grid"][b]) > action_deadband: + continue + if float(solve_result["discharge"][b]) > action_deadband: + continue + + available = _battery_available_discharge_kwh( + entity, float(battery_levels[b, t]) + ) + if ( + float(entity.minimum_kwh) > EPSILON + and available <= action_deadband + ): + battery_preserve[b, t] = True + continue + + probe_kwh = _battery_preserve_probe_kwh( + entity, float(battery_levels[b, t]) + ) + if probe_kwh <= action_deadband: + continue + + # A preserve policy is about unexpected real load, not the + # forecast load already in the plan. If PV surplus exists, the + # marginal load would consume that PV first. Only the next + # probe-sized slice asks whether spending battery is worse than + # importing and preserving it for later value. + counterfactual_usage_h = usage_h.copy() + counterfactual_usage_h[0] += pv_surplus + probe_kwh + preserve_baseline_objective = ( + float(solve_result["objective_value"]) + + float(grid_export_prices[t]) * pv_surplus + + float(prices[t]) * probe_kwh + ) + counterfactual = _solve_mpc_step( + base_timeslot=t, + prices_h=prices[t : t + horizon], + grid_export_prices_h=grid_export_prices[t : t + horizon], + usage_h=counterfactual_usage_h, + solar_h=solar_input[t : t + horizon], + 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), + prev_comfort_on=prev_comfort_on[unlocked_indices] + if unlocked_indices + else np.zeros(0, dtype=np.float64), + comfort_off_streaks_now=comfort_off_streaks[ + unlocked_indices, t + ] + if unlocked_indices + else np.zeros(0, dtype=np.float64), + remaining_steps_total=total_steps - t, + forced_discharge_first={b: probe_kwh}, + ) + if counterfactual is None or _objective_is_worse( + counterfactual["objective_value"], + preserve_baseline_objective, + ): + battery_preserve[b, t] = True if num_comfort > 0: comfort_lock_mode_series[:, t] = comfort_lock_mode.astype(np.float64) @@ -1403,6 +1410,7 @@ def optimize_internal(normalized: CalculationInput): battery_entities, comfort_entities, reuse_plan, + normalized.infer_battery_preserve_policy, ) execution_time = time.time() - start_time diff --git a/docs/entities-and-services.md b/docs/entities-and-services.md index adf5805..260aa7e 100644 --- a/docs/entities-and-services.md +++ b/docs/entities-and-services.md @@ -40,7 +40,7 @@ These exist once per configured battery: | Entity | Purpose | | --- | --- | -| `sensor.__action` | Current battery-control policy: `preserve`, `self_consume`, or `grid_charge`. WattPlan updates this entity on its planning schedule so **your own automation can translate the policy into a real inverter or battery command**. The state is a policy derived from the plan, not a raw forecast battery-flow value. | +| `sensor.__action` | Current battery-control policy: `preserve`, `self_consume`, or `grid_charge`. WattPlan updates this entity on its planning schedule so **your own automation can translate the policy into a real inverter or battery command**. The state is a policy derived from the plan, not a raw forecast battery-flow value. See [extras.md](extras.md#real-life-examples) for automation examples. | | `sensor.__target` | User-supplied target SoC in kWh. Includes a `by` attribute with the requested deadline and returns `unknown` when no active target is set. | ## Comfort Load Entities diff --git a/docs/extras.md b/docs/extras.md index 3dd9938..bd60e9a 100644 --- a/docs/extras.md +++ b/docs/extras.md @@ -44,6 +44,8 @@ WattPlan exposes battery-related entities such as: The battery action sensor is the key one for control. Your automation should read that action and then translate it into the command model your inverter understands. +See [Real Life Examples](#real-life-examples) for a concrete Home Assistant automation pattern. + **Typical Pattern:** 1. Create an automation that triggers when the WattPlan battery action entity changes. 2. Read the action value from WattPlan. @@ -63,7 +65,10 @@ PV surplus handling is not a battery action state in this version. PV export is The exact translation depends on your inverter integration. WattPlan does not directly control every battery platform; it publishes the intended action and lets your automations bridge that to your actual system. -### Solar Assistant/MQTT/Inverter Example +## Real Life Examples +The examples below show how WattPlan policy entities can be translated into real Home Assistant device controls. They are starting points, not universal recipes. Check your inverter, load controller, and integration behavior before applying an automation to real hardware. + +### Home Assistant to Solar Assistant MQTT to Deye-Compatible Inverter This example describes one practical setup: Home Assistant entities exposed by Solar Assistant over MQTT for a Deye-compatible inverter. It is not universal. Other inverter brands may map the same three WattPlan policies to different entities or services. In this style of setup, the inverter time-of-use schedule controls can be more reliable than direct mode controls. A time-of-use capacity point is used to allow or block battery discharge, and a grid charge point switch is used to enable scheduled grid charging. Max charge/current entities may also exist, but grid charge current can often be treated as a configured cap instead of being changed on every policy update. @@ -84,6 +89,66 @@ Example time-of-use mapping: | `self_consume` | Normal minimum, for example `10%` | Off | Normal/static | | `grid_charge` | High, for example `100%`, to preserve while charging | On | Configured normal charging cap | +Example automation: + +```yaml +alias: Apply WattPlan battery policy +mode: single +triggers: + - trigger: state + entity_id: sensor.wattplan_house_battery_action +conditions: + - condition: template + value_template: "{{ trigger.to_state.state in ['preserve', 'self_consume', 'grid_charge'] }}" +actions: + - variables: + policy: "{{ trigger.to_state.state }}" + normal_minimum_soc: 10 + preserve_soc: 100 + - choose: + - alias: "preserve: block discharge, no grid charge" + conditions: + - condition: template + value_template: "{{ policy == 'preserve' }}" + sequence: + - action: switch.turn_off + target: + entity_id: switch.inverter_grid_charge_point_1 + - action: number.set_value + target: + entity_id: number.inverter_tou_capacity_point_1 + data: + value: "{{ preserve_soc }}" + - alias: "self_consume: allow discharge, no grid charge" + conditions: + - condition: template + value_template: "{{ policy == 'self_consume' }}" + sequence: + - action: switch.turn_off + target: + entity_id: switch.inverter_grid_charge_point_1 + - action: number.set_value + target: + entity_id: number.inverter_tou_capacity_point_1 + data: + value: "{{ normal_minimum_soc }}" + - alias: "grid_charge: block discharge, enable grid charge" + conditions: + - condition: template + value_template: "{{ policy == 'grid_charge' }}" + sequence: + - action: number.set_value + target: + entity_id: number.inverter_tou_capacity_point_1 + data: + value: "{{ preserve_soc }}" + - action: switch.turn_on + target: + entity_id: switch.inverter_grid_charge_point_1 +``` + +The charge-current and grid-charge-current entities are intentionally not part of this example's policy switch. In many setups those values are a configured cap, such as `80 A`, and do not need to be toggled every time WattPlan changes policy. If your own setup needs dynamic current limits, handle them as an extra device-specific rule around this core three-policy mapping. + ## Comfort Loads ### What They Are @@ -188,6 +253,8 @@ That might be: - A select entity - A script that applies a complete inverter mode change. +See [Real Life Examples](#real-life-examples) for one concrete mapping. + ### Can I start with forecasts only and add extras later? Yes. That is the recommended path: 1. Get price and usage working. diff --git a/docs/optimizer-api.md b/docs/optimizer-api.md index 362bd5d..5928b84 100644 --- a/docs/optimizer-api.md +++ b/docs/optimizer-api.md @@ -41,6 +41,7 @@ result = optimize(params) | `throughput_cost_per_kwh` | `float` | No | `0.0` | Finite, `>= 0` | Extra cost on charge/discharge throughput to reduce cycling. | | `action_deadband_kwh` | `float` | No | `0.0` | Finite, `>= 0` | Modeled flow commands smaller than this are treated as neutral flow. | | `mode_switch_cost` | `float` | No | `0.0` | Finite, `>= 0` | Extra cost on changing battery behavior between slots. | +| `infer_battery_preserve_policy` | `bool` | No | `true` | - | Enables the model-backed counterfactual used to emit `preserve` battery policy states. When disabled, all `battery_preserve` booleans are `false` and non-grid-charging battery slots fall back to `self_consume`. | | `battery_entities` | `list[BatteryEntityParams]` | Yes | - | May be empty | Main controllable storage entities. | | `comfort_entities` | `list[ComfortEntityParams]` | Yes | - | May be empty | Required-but-shiftable comfort entities. | | `optional_entities` | `list[OptionalEntityParams]` | No | `[]` | Fully validated for feasibility | Advisory start-time options only. | @@ -223,7 +224,9 @@ Battery schedule `state` values are inverter-control policies derived from the p PV surplus charging is implicit/normal battery behavior, not a primary action state. PV export is site-level and multi-battery-sensitive, so a dedicated PV export policy is deferred to a future site-level design. -`grid_charge` is emitted when modeled grid charging for that battery is above the action deadband. `preserve` is emitted from a model-backed counterfactual check: when 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. +`grid_charge` is emitted when modeled grid charging for that battery is above the action deadband. `preserve` is emitted from a model-backed counterfactual check when `infer_battery_preserve_policy` is enabled: when the optimizer chooses not to discharge a battery, WattPlan asks the same model whether forcing a small discharge from that battery for marginal unexpected load would be infeasible or make the objective worse than preserving the battery and importing that marginal energy. Modeled PV surplus is consumed first in that counterfactual, so PV export is not turned into a battery action state. If the forced-discharge alternative is worse, the slot is marked `preserve`; otherwise WattPlan emits `self_consume`. Forecast zero battery flow is not a preserve reason by itself. + +If `infer_battery_preserve_policy` is disabled, the `battery_preserve` boolean array is always `false`. In that mode, the schedule still emits `grid_charge` for modeled grid charging, but otherwise emits `self_consume` for battery slots. ### Notes on `entities` and `optional_entity_options` - `entities` is the actual optimized schedule. diff --git a/tests/optimizer/test_optimizer_scenarios.py b/tests/optimizer/test_optimizer_scenarios.py index 24b3ba2..23a2a70 100644 --- a/tests/optimizer/test_optimizer_scenarios.py +++ b/tests/optimizer/test_optimizer_scenarios.py @@ -666,6 +666,34 @@ def test_model_marks_preserve_for_marginal_load_after_pv_surplus(): assert schedule[1]["level"] == pytest.approx(0.0) +def test_preserve_policy_inference_can_be_disabled(): + payload = { + "grid_import_price_per_kwh": [0.10, 1.00, 1.00, 1.00], + "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": [1.0, 1.0, 1.0, 1.0], + "infer_battery_preserve_policy": False, + "battery_entities": [ + { + "name": "battery", + "initial_kwh": 1.0, + "minimum_kwh": 0.0, + "capacity_kwh": 1.0, + "charge_curve_kwh": [0.0], + "discharge_curve_kwh": [1.0], + "can_charge_from": 0, + } + ], + "comfort_entities": [], + } + + result = _run_optimizer(payload) + schedule = result["entities"][0]["schedule"] + + assert schedule[0]["state"] == "self_consume" + assert all(point["state"] != "preserve" for point in schedule) + + def test_battery_target_does_not_create_preserve_policy_by_itself(): payload = { "grid_import_price_per_kwh": [0.10, 0.10, 0.10, 0.10], From 4cefb31095e2f91752c3411c51175326ac13e9d3 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 22 Apr 2026 15:24:05 +0200 Subject: [PATCH 5/6] Cover battery policy state transitions --- tests/optimizer/test_optimizer_scenarios.py | 99 ++++++++++++++------- 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/tests/optimizer/test_optimizer_scenarios.py b/tests/optimizer/test_optimizer_scenarios.py index 23a2a70..c6c851f 100644 --- a/tests/optimizer/test_optimizer_scenarios.py +++ b/tests/optimizer/test_optimizer_scenarios.py @@ -723,12 +723,13 @@ def test_battery_target_does_not_create_preserve_policy_by_itself(): ) -def test_live_exported_deye_forecast_self_consumes_until_evening_discharge(): +def test_live_exported_deye_low_pv_low_soc_flips_between_battery_policies(): # Exported from wattplan.export_planner_input on 2026-04-22 for the live - # Home Assistant setup. Although the battery remains full until the evening - # price rise, the model says self-consume is still the correct policy: early - # marginal load is covered by PV surplus or can be recovered before the - # high-value discharge window. + # Home Assistant setup, then adjusted to model a mediocre PV day with the + # battery starting at 30% SoC. This is intentionally close to the real case + # we investigated: self-consume is normal operation, grid-charge is used to + # prepare for evening value, and preserve appears only once the battery is + # exhausted to its configured minimum. payload = { "grid_import_price_per_kwh": [ 0.577338, @@ -799,29 +800,29 @@ def test_live_exported_deye_forecast_self_consumes_until_evening_discharge(): 0.080993, ], "solar_input_kwh": [ - 4.749, - 2.2255, - 2.2255, - 2.0435, - 2.0435, - 1.8395, - 1.8395, - 1.5905, - 1.5905, - 1.313, - 1.313, - 1.0025, - 1.0025, - 0.71, - 0.71, - 0.3945, - 0.3945, - 0.1235, - 0.1235, - 0.032, - 0.032, - 0.0095, - 0.0095, + 0.4749, + 0.22255, + 0.22255, + 0.20435, + 0.20435, + 0.18395, + 0.18395, + 0.15905, + 0.15905, + 0.1313, + 0.1313, + 0.10025, + 0.10025, + 0.071, + 0.071, + 0.03945, + 0.03945, + 0.01235, + 0.01235, + 0.0032, + 0.0032, + 0.00095, + 0.00095, 0.0, 0.0, 0.0, @@ -873,7 +874,7 @@ def test_live_exported_deye_forecast_self_consumes_until_evening_discharge(): "battery_entities": [ { "name": "Battery", - "initial_kwh": 10.0, + "initial_kwh": 3.0, "minimum_kwh": 1.0, "capacity_kwh": 10.0, "charge_efficiency": 0.9, @@ -901,9 +902,43 @@ def test_live_exported_deye_forecast_self_consumes_until_evening_discharge(): result = _run_optimizer(payload) schedule = result["entities"][0]["schedule"] - assert {point["state"] for point in schedule} == {"self_consume"} - assert all(point["level"] == pytest.approx(10.0) for point in schedule[:17]) - assert schedule[17]["level"] < 10.0 + assert [point["state"] for point in schedule] == [ + "self_consume", + "grid_charge", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "grid_charge", + "grid_charge", + "grid_charge", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "self_consume", + "preserve", + "preserve", + ] + assert schedule[0]["level"] > 3.0 + assert schedule[8]["level"] > schedule[0]["level"] + assert schedule[29]["level"] == pytest.approx(1.0) def test_live_grid_export_benchmark_scenario_uses_real_15min_stromligning_values(): From 9f36ebf2fe5baf4f5ca0f197e7bb80f4f3def514 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 22 Apr 2026 15:31:22 +0200 Subject: [PATCH 6/6] Address battery policy docs feedback --- README.md | 2 +- docs/extras.md | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d0b2ce0..7c1d47e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This documentation is intended for Home Assistant users, energy enthusiasts, and 8. Optionally configure a PV source if you have solar and want WattPlan to plan around it. 9. Optionally configure an export price source if you have PV and want exported power to carry a value instead of defaulting to zero. 10. Add [batteries, comfort loads, or optional loads](docs/extras.md) if you want WattPlan to control more than just forecasting. -11. Make automations to apply the WattPlan actions to your devices, such as setting batteries to charge or starting your HVAC. See the [real-life examples](docs/extras.md#real-life-examples) for one battery policy automation pattern. +11. Make automations to apply the WattPlan actions to your devices, such as setting batteries to charge or starting your HVAC. See the [real-life examples](docs/extras.md#real-life-examples) for practical automation patterns. ## Configuration Steps After installing WattPlan via HACS, configure the following: diff --git a/docs/extras.md b/docs/extras.md index bd60e9a..0204cce 100644 --- a/docs/extras.md +++ b/docs/extras.md @@ -69,9 +69,9 @@ The exact translation depends on your inverter integration. WattPlan does not di The examples below show how WattPlan policy entities can be translated into real Home Assistant device controls. They are starting points, not universal recipes. Check your inverter, load controller, and integration behavior before applying an automation to real hardware. ### Home Assistant to Solar Assistant MQTT to Deye-Compatible Inverter -This example describes one practical setup: Home Assistant entities exposed by Solar Assistant over MQTT for a Deye-compatible inverter. It is not universal. Other inverter brands may map the same three WattPlan policies to different entities or services. +This example describes one practical setup: Home Assistant entities exposed by [Solar Assistant](https://solar-assistant.io/) over MQTT for a Deye-compatible inverter, such as a [Deye SUN-12K](https://deye.com/da/product/sun-5-6-8-10-12k-sg04lp3-eu/). It is not universal. Other inverter brands may map the same three WattPlan policies to different entities or services. -In this style of setup, the inverter time-of-use schedule controls can be more reliable than direct mode controls. A time-of-use capacity point is used to allow or block battery discharge, and a grid charge point switch is used to enable scheduled grid charging. Max charge/current entities may also exist, but grid charge current can often be treated as a configured cap instead of being changed on every policy update. +In this style of setup, the inverter time-of-use schedule controls can be more reliable than direct mode controls. A time-of-use capacity point is used to allow or block battery discharge, and a grid charge point switch is used to enable scheduled grid charging. Generic policy mapping: @@ -83,13 +83,14 @@ Generic policy mapping: Example time-of-use mapping: -| WattPlan policy | Time-of-use capacity point | Grid charge point | Charge current | -| --- | --- | --- | --- | -| `preserve` | High, for example `100%`, to prevent discharge | Off | Normal/static unless the setup needs otherwise | -| `self_consume` | Normal minimum, for example `10%` | Off | Normal/static | -| `grid_charge` | High, for example `100%`, to preserve while charging | On | Configured normal charging cap | +| WattPlan policy | Time-of-use capacity point | Grid charge point | +| --- | --- | --- | +| `preserve` | High, for example `100%`, to prevent discharge | Off | +| `self_consume` | Normal minimum, for example `10%` | Off | +| `grid_charge` | High, for example `100%`, to preserve while charging | On | -Example automation: +
+Example automation ```yaml alias: Apply WattPlan battery policy @@ -147,7 +148,7 @@ actions: entity_id: switch.inverter_grid_charge_point_1 ``` -The charge-current and grid-charge-current entities are intentionally not part of this example's policy switch. In many setups those values are a configured cap, such as `80 A`, and do not need to be toggled every time WattPlan changes policy. If your own setup needs dynamic current limits, handle them as an extra device-specific rule around this core three-policy mapping. +
## Comfort Loads