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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/entities-and-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ These exist once per WattPlan setup:
| `sensor.<setup_slug>_next_run` | Timestamp of the next scheduled planning cycle. |
| `sensor.<setup_slug>_last_run_duration` | Duration of the last planning cycle in milliseconds. |
| `sensor.<setup_slug>_projected_cost_savings` | Horizon-wide cost savings for the current plan. |
| `sensor.<setup_slug>_projected_savings_percentage` | Horizon-wide savings percentage for the current plan. |
| `sensor.<setup_slug>_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.<setup_slug>_projected_cost_savings_next_interval` | Disabled by default. Savings for the next planner interval only. |
| `sensor.<setup_slug>_projected_savings_percentage_next_interval` | Disabled by default. Savings percentage for the next planner interval only. |
| `sensor.<setup_slug>_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.<setup_slug>_plan_details` | Disabled by default. Raw planner-detail payload at WattPlan's configured slot size. |
| `sensor.<setup_slug>_plan_details_hourly` | Disabled by default. The same planner details, aggregated to hourly buckets. |
| `sensor.<setup_slug>_usage_forecast` | Present when the built-in usage source is configured. Exposes the generated usage forecast. |
Expand Down
2 changes: 1 addition & 1 deletion docs/optimizer-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
103 changes: 84 additions & 19 deletions src/custom_components/wattplan/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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")
Expand All @@ -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."""
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions tests/integration/test_integration_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Loading