diff --git a/docs/entities-and-services.md b/docs/entities-and-services.md index 0ebb493..ca865b6 100644 --- a/docs/entities-and-services.md +++ b/docs/entities-and-services.md @@ -25,9 +25,9 @@ These exist once per WattPlan setup: | `sensor._next_run` | Timestamp of the next scheduled planning cycle. | | `sensor._last_run_duration` | Duration of the last planning cycle in milliseconds. | | `sensor._projected_cost_savings` | Horizon-wide cost savings for the current plan. | -| `sensor._projected_savings_percentage` | Horizon-wide savings percentage for the current plan. | +| `sensor._projected_savings_percentage` | Horizon-wide savings percentage for the current plan. Uses `(1 - projected_cost / baseline_cost) * 100` and exposes the component costs as attributes. Returns `unknown` when the resulting percentage exceeds WattPlan's current sanity threshold. | | `sensor._projected_cost_savings_next_interval` | Disabled by default. Savings for the next planner interval only. | -| `sensor._projected_savings_percentage_next_interval` | Disabled by default. Savings percentage for the next planner interval only. | +| `sensor._projected_savings_percentage_next_interval` | Disabled by default. Savings percentage for the next planner interval only, with the same formula, attributes, and sanity-threshold behavior as the horizon sensor. | | `sensor._plan_details` | Disabled by default. Raw planner-detail payload at WattPlan's configured slot size. | | `sensor._plan_details_hourly` | Disabled by default. The same planner details, aggregated to hourly buckets. | | `sensor._usage_forecast` | Present when the built-in usage source is configured. Exposes the generated usage forecast. | diff --git a/docs/optimizer-api.md b/docs/optimizer-api.md index 3ade86d..ae88733 100644 --- a/docs/optimizer-api.md +++ b/docs/optimizer-api.md @@ -222,7 +222,7 @@ Optional entities provide advisory start-time suggestions and do not change the - `baseline_cost`: Baseline net energy cost across the horizon, including export revenue when `grid_export_price_per_kwh` is provided. - `projected_cost`: Projected net cost for the optimized schedule (`grid imports - grid export revenue`). - `projected_savings_cost`: `baseline_cost - projected_cost`. -- `projected_savings_pct`: `(projected_savings_cost / baseline_cost) * 100`, or `0` when baseline is `0`. +- `projected_savings_pct`: `(1 - projected_cost / baseline_cost) * 100`, which is equivalent to `(projected_savings_cost / baseline_cost) * 100` when `baseline_cost > 0`. The optimizer still emits the raw numeric result; Home Assistant sensors may choose not to expose extreme values as entity state. - `per_slot`: List with one object per timeslot (same index/order as input arrays), each containing: - `baseline_cost` - `projected_cost` diff --git a/src/custom_components/wattplan/sensor.py b/src/custom_components/wattplan/sensor.py index 494bf23..bc7acfe 100644 --- a/src/custom_components/wattplan/sensor.py +++ b/src/custom_components/wattplan/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import Any, Callable from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -58,6 +58,9 @@ "gp": "(G)rid and (P)V", } +MAX_EXPOSED_PROJECTED_SAVINGS_PCT = 200.0 +ProjectionValueTransform = Callable[["ProjectionSensor", float], float | None] + ENTRY_FRIENDLY_NAMES: dict[str, str] = { "status": "Status", "status_message": "Status Message", @@ -153,6 +156,15 @@ def _friendly_charge_source_label(charge_source: str) -> str: return BATTERY_CHARGE_SOURCE_LABELS.get(charge_source, charge_source) +def _projected_savings_percentage_value_transform( + _sensor: ProjectionSensor, value: float +) -> float | None: + """Hide implausibly large savings percentages from the entity state.""" + if abs(value) > MAX_EXPOSED_PROJECTED_SAVINGS_PCT: + return None + return value + + class WattPlanCoordinatorSensor(CoordinatorEntity[WattPlanCoordinator], SensorEntity): """Base WattPlan sensor backed by coordinator snapshots.""" @@ -680,6 +692,7 @@ def __init__( *, projection_key: str, aggregate_mode: str = "horizon", + value_transform: ProjectionValueTransform | None = None, use_home_currency: bool = False, native_unit_of_measurement: str | None = None, **kwargs: Any, @@ -688,6 +701,7 @@ def __init__( super().__init__(config_entry, coordinator, **kwargs) self._projection_key = projection_key self._aggregate_mode = aggregate_mode + self._value_transform = value_transform if use_home_currency: self._attr_native_unit_of_measurement = coordinator.hass.config.currency self._attr_suggested_display_precision = 2 @@ -704,24 +718,18 @@ def native_value(self) -> float | None: optimizer = self._optimizer_diagnostics() if optimizer is None: return None - projections = optimizer.get("projections") - if not isinstance(projections, dict): + projections = self._projections(optimizer) + if projections is None: return None - if self._aggregate_mode == "horizon": - try: - return float(projections[self._projection_key]) - except (KeyError, TypeError, ValueError): - return None - per_slot = projections.get("per_slot") - if not isinstance(per_slot, list) or not per_slot: + series = self._selected_projection_series(projections) + if series is None: return None - first_slot = per_slot[0] - if not isinstance(first_slot, dict): - return None - try: - return float(first_slot[self._projection_key]) - except (KeyError, TypeError, ValueError): + value = self._coerce_projection_value(series, self._projection_key) + if value is None: return None + if self._value_transform is not None: + return self._value_transform(self, value) + return value @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -731,11 +739,11 @@ def extra_state_attributes(self) -> dict[str, Any] | None: return None span_start = optimizer.get("span_start") span_end = optimizer.get("span_end") - projections = optimizer.get("projections") + projections = self._projections(optimizer) if ( not isinstance(span_start, str) or not isinstance(span_end, str) - or not isinstance(projections, dict) + or projections is None ): return None per_slot = projections.get("per_slot") @@ -751,12 +759,30 @@ def extra_state_attributes(self) -> dict[str, Any] | None: except (KeyError, TypeError, ValueError): continue - return { + attributes: dict[str, Any] = { "span_start": span_start, "span_end": span_end, "total": projections.get(self._projection_key), "values": values, } + if self._projection_key == "projected_savings_pct": + attributes["formula"] = "(1 - projected_cost / baseline_cost) * 100" + attributes["baseline_cost"] = projections.get("baseline_cost") + attributes["projected_cost"] = projections.get("projected_cost") + attributes["projected_savings_cost"] = projections.get( + "projected_savings_cost" + ) + attributes["baseline_cost_values"] = self._per_slot_values( + per_slot, "baseline_cost" + ) + attributes["projected_cost_values"] = self._per_slot_values( + per_slot, "projected_cost" + ) + attributes["projected_savings_cost_values"] = self._per_slot_values( + per_slot, "projected_savings_cost" + ) + attributes["max_exposed_percentage"] = MAX_EXPOSED_PROJECTED_SAVINGS_PCT + return attributes def _optimizer_diagnostics(self) -> dict[str, Any] | None: """Return optimizer diagnostics from the current snapshot.""" @@ -768,6 +794,43 @@ def _optimizer_diagnostics(self) -> dict[str, Any] | None: return None return optimizer + def _projections(self, optimizer: dict[str, Any]) -> dict[str, Any] | None: + """Return optimizer projections when present.""" + projections = optimizer.get("projections") + return projections if isinstance(projections, dict) else None + + def _selected_projection_series( + self, projections: dict[str, Any] + ) -> dict[str, Any] | None: + """Return the active projection aggregate for this sensor.""" + if self._aggregate_mode == "horizon": + return projections + per_slot = projections.get("per_slot") + if not isinstance(per_slot, list) or not per_slot: + return None + first_slot = per_slot[0] + return first_slot if isinstance(first_slot, dict) else None + + def _coerce_projection_value( + self, source: dict[str, Any], key: str + ) -> float | None: + """Return a numeric projection value when available.""" + try: + return float(source[key]) + except (KeyError, TypeError, ValueError): + return None + + def _per_slot_values(self, per_slot: list[Any], key: str) -> list[float]: + """Return one numeric per-slot projection series.""" + values: list[float] = [] + for slot in per_slot: + if not isinstance(slot, dict): + continue + value = self._coerce_projection_value(slot, key) + if value is not None: + values.append(value) + return values + async def async_setup_entry( hass: HomeAssistant, @@ -863,6 +926,7 @@ async def async_setup_entry( config_entry, coordinator, projection_key="projected_savings_pct", + value_transform=_projected_savings_percentage_value_transform, aggregate_mode="horizon", friendly_name=_entry_sensor_name( "projected_savings_percentage", @@ -891,6 +955,7 @@ async def async_setup_entry( config_entry, coordinator, projection_key="projected_savings_pct", + value_transform=_projected_savings_percentage_value_transform, aggregate_mode="next_interval", friendly_name=_entry_sensor_name( "projected_savings_percentage_this_interval", diff --git a/tests/integration/test_integration_runtime.py b/tests/integration/test_integration_runtime.py index c505d1d..534a422 100644 --- a/tests/integration/test_integration_runtime.py +++ b/tests/integration/test_integration_runtime.py @@ -200,6 +200,37 @@ def _fake_optimize_with_target_behavior(params: object) -> dict[str, object]: } +def _fake_optimize_with_extreme_savings(_params: object) -> dict[str, object]: + """Return a plan whose percentage savings should be hidden as unknown.""" + return { + "execution_time": 0.01, + "fitness": 1.0, + "avg_price": 0.25, + "projections": { + "baseline_cost": 0.1, + "projected_cost": -1.4, + "projected_savings_cost": 1.5, + "projected_savings_pct": 1500.0, + "per_slot": [ + { + "baseline_cost": 0.1, + "projected_cost": -1.4, + "projected_savings_cost": 1.5, + "projected_savings_pct": 1500.0, + } + ], + }, + "suboptimal": False, + "suboptimal_reasons": [], + "problems": [], + "successful_solves": 1, + "reused_steps": 0, + "entities": [], + "optional_entity_options": [], + "state": None, + } + + def _assert_valid_state(hass: HomeAssistant, entity_id: str) -> None: @@ -372,6 +403,14 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: assert savings_pct.attributes["span_end"] == savings.attributes["span_end"] assert savings_pct.attributes["total"] == 24.0 assert savings_pct.attributes["values"] == [25.0, 33.333333, 25.0, 14.285714] + assert savings_pct.attributes["formula"] == "(1 - projected_cost / baseline_cost) * 100" + assert savings_pct.attributes["baseline_cost"] == 12.5 + assert savings_pct.attributes["projected_cost"] == 9.5 + assert savings_pct.attributes["projected_savings_cost"] == 3.0 + assert savings_pct.attributes["baseline_cost_values"] == [2.0, 3.0, 4.0, 3.5] + assert savings_pct.attributes["projected_cost_values"] == [1.5, 2.0, 3.0, 3.0] + assert savings_pct.attributes["projected_savings_cost_values"] == [0.5, 1.0, 1.0, 0.5] + assert savings_pct.attributes["max_exposed_percentage"] == 200.0 assert ( savings_pct.attributes["friendly_name"] == "Projected Savings Percentage over 4h" @@ -406,6 +445,52 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: assert option_1.attributes["friendly_name"] == "(optional) Option 1 Start" +async def test_projected_savings_percentage_becomes_unknown_when_extreme( + hass: HomeAssistant, +) -> None: + """Hide implausibly large projected savings percentages while keeping components.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + data={ + CONF_NAME: "Home", + CONF_SLOT_MINUTES: 60, + CONF_HOURS_TO_PLAN: 4, + CONF_SOURCES: { + CONF_SOURCE_IMPORT_PRICE: { + CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, + CONF_TEMPLATE: "{{ [0.2, 0.2, 0.2, 0.2] }}", + }, + CONF_SOURCE_USAGE: { + CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, + CONF_TEMPLATE: "{{ [1.0, 1.0, 1.0, 1.0] }}", + }, + }, + }, + options={ + CONF_PLANNING_ENABLED: False, + CONF_ACTION_EMISSION_ENABLED: False, + }, + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.wattplan.coordinator.optimize", + side_effect=_fake_optimize_with_extreme_savings, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + savings_pct = hass.states.get("sensor.home_projected_savings_percentage") + assert savings_pct is not None + assert savings_pct.state == STATE_UNKNOWN + assert savings_pct.attributes["total"] == 1500.0 + assert savings_pct.attributes["baseline_cost"] == 0.1 + assert savings_pct.attributes["projected_cost"] == -1.4 + assert savings_pct.attributes["projected_savings_cost"] == 1.5 + assert savings_pct.attributes["max_exposed_percentage"] == 200.0 + + async def test_battery_action_sensor_exposes_friendly_combined_charge_source( hass: HomeAssistant, ) -> None: