diff --git a/README.md b/README.md index 7c1d47e..c766e2f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This documentation is intended for Home Assistant users, energy enthusiasts, and 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 practical automation patterns. +12. When the setup is operating, optionally enable [historical cost tracking](docs/historical-cost-tracking.md) if you want to measure whether it is actually improving cost over time. ## Configuration Steps After installing WattPlan via HACS, configure the following: @@ -35,30 +36,34 @@ After installing WattPlan via HACS, configure the following: - **PV Source**: Optional. Set up your solar production data source if applicable. - **Export Price Source**: Optional. If PV is configured, you can provide a value for exported power. Otherwise WattPlan treats export value as zero. - **Optional Loads**: Optional. Configure any additional loads you wish to manage, such as batteries or comfort loads. +- **Historical Cost Tracking**: Optional and disabled by default. See [Historical Cost Tracking](docs/historical-cost-tracking.md) for setup requirements and how to read the numbers. + +## Tracking Performance +Use [Historical Cost Tracking](docs/historical-cost-tracking.md) to compare measured cost against reference scenarios and understand whether positive or negative savings values are good or bad. ## Features - Home Assistant custom integration with HACS-ready release artifacts - Config-flow driven source setup for import price, export price, usage, and PV inputs - Battery, comfort-load, and optional-load planning - Planned actions are exposed as entities, so you can easily use the results to do automations +- Optional historical cost tracking for comparing actual cost against simple reference scenarios - Battery targets can be set and cleared through WattPlan services - GitHub Actions for CI, tagged releases, prereleases, and `main` branch dev artifacts ## 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, 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 -- [docs/development.md](docs/development.md) - Local setup with `uv`, local test env caveats, optional symlink workflow, packaging -- [docs/architecture.md](docs/architecture.md) - Code layout, runtime boundaries, planning flow -- [docs/release.md](docs/release.md) - Tags, prereleases, dev artifacts, GitHub release assets -- [docs/optimizer-api.md](docs/optimizer-api.md) - Direct optimizer API notes +- [Source Data](docs/source-data.md) - Source modes, data model, and how to feed WattPlan price, export price, usage, and PV data +- [Historical Cost Tracking](docs/historical-cost-tracking.md) - Historical setup requirements, scenarios, entities, and how to read savings values +- [Deye, Solcast, and Strømligning Example](docs/example-deye-solcast-stromligning.md) - Concrete end-to-end example using Strømligning, Deye, and Solcast +- [Extras and Automations](docs/extras.md) - Batteries, comfort loads, optional loads, real-life examples, and how to wire WattPlan actions into your own automations +- [Entities and Services](docs/entities-and-services.md) - Planner, battery, load entities, services, and battery targets +- [Optimizer Profiles](docs/optimizer-profiles.md) - What Aggressive, Balanced, and Conservative mean in practice +- [Error Handling](docs/error-handling.md) - Health states, degraded operation, and what `ok`, `degraded`, and `failed` mean +- [Development](docs/development.md) - Local setup with `uv`, local test env caveats, optional symlink workflow, packaging +- [Architecture](docs/architecture.md) - Code layout, runtime boundaries, planning flow +- [Release Process](docs/release.md) - Tags, prereleases, dev artifacts, GitHub release assets +- [Optimizer API](docs/optimizer-api.md) - Direct optimizer API notes ## Limitations While WattPlan is designed to optimize energy usage effectively, there are scenarios where it may not be the best fit: - Users with highly variable energy prices may find it challenging to predict optimal usage. - Integration with certain legacy systems may require additional configuration or may not be supported. - -## Status diff --git a/custom_components/wattplan/button.py b/custom_components/wattplan/button.py index c123316..1e13ef9 100644 --- a/custom_components/wattplan/button.py +++ b/custom_components/wattplan/button.py @@ -50,6 +50,8 @@ class RunOptimizeNowButton(WattPlanButton): async def async_press(self) -> None: """Run the planning stage immediately.""" await self.coordinator.async_plan(trigger=CycleTrigger.SERVICE) + if self._config_entry.runtime_data.historical_tracker is not None: + await self._config_entry.runtime_data.historical_tracker.async_refresh() mark_runtime_updated(self._config_entry.runtime_data, when=datetime.now(tz=UTC)) @@ -60,6 +62,8 @@ class RefreshSensorsButton(WattPlanButton): async def async_press(self) -> None: """Run the emission stage immediately.""" + if self._config_entry.runtime_data.historical_tracker is not None: + await self._config_entry.runtime_data.historical_tracker.async_refresh() await self.coordinator.async_emit(trigger=CycleTrigger.SERVICE) diff --git a/custom_components/wattplan/const.py b/custom_components/wattplan/const.py index 36e86b4..b82d75e 100644 --- a/custom_components/wattplan/const.py +++ b/custom_components/wattplan/const.py @@ -64,6 +64,13 @@ CONF_SOURCE_PV = "pv" CONF_SOURCE_USAGE = "usage" CONF_HISTORY_DAYS = "history_days" +CONF_HISTORICAL_COST_TRACKING_ENABLED = "historical_cost_tracking_enabled" +CONF_HISTORICAL_GRID_IMPORT_SENSOR = "historical_grid_import_sensor" +CONF_HISTORICAL_GRID_EXPORT_SENSOR = "historical_grid_export_sensor" +CONF_HISTORICAL_USAGE_SENSOR = "historical_usage_sensor" +CONF_HISTORICAL_PV_SENSOR = "historical_pv_sensor" +CONF_HISTORICAL_SIMULATE_NO_BATTERY = "historical_simulate_no_battery" +CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION = "historical_simulate_self_consumption" CONF_TEMPLATE = "template" CONF_TARGET_ON_HOURS_PER_WINDOW = "target_on_hours_per_window" CONF_TIME_KEY = "time_key" diff --git a/custom_components/wattplan/coordinator.py b/custom_components/wattplan/coordinator.py index 5b5b4d5..60b53fa 100644 --- a/custom_components/wattplan/coordinator.py +++ b/custom_components/wattplan/coordinator.py @@ -284,6 +284,7 @@ async def _async_update_data(self) -> CoordinatorSnapshot | None: async def async_tick(self, *, trigger: CycleTrigger) -> None: """Run one fixed-interval tick with conditional stage execution.""" if not self.scheduler_enabled and trigger is CycleTrigger.SCHEDULE: + await self._async_refresh_historical(trigger=trigger) return self._last_attempt_at = datetime.now(tz=UTC) @@ -309,6 +310,27 @@ async def async_tick(self, *, trigger: CycleTrigger) -> None: err, ) + await self._async_refresh_historical(trigger=trigger) + + async def _async_refresh_historical(self, *, trigger: CycleTrigger) -> None: + """Refresh historical cost state when the entry has a tracker.""" + entry = self.hass.config_entries.async_get_entry(self._entry_id) + historical_tracker = ( + getattr(entry.runtime_data, "historical_tracker", None) + if entry is not None and hasattr(entry, "runtime_data") + else None + ) + if historical_tracker is not None: + try: + await historical_tracker.async_refresh() + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "Historical refresh failed (entry_id=%s, trigger=%s): %s", + self._entry_id, + trigger, + err, + ) + async def async_plan(self, *, trigger: CycleTrigger) -> None: """Run the planning stage and replace the immutable snapshot.""" if self._plan_lock.locked(): @@ -330,6 +352,7 @@ async def async_plan(self, *, trigger: CycleTrigger) -> None: planner_result = await self._async_run_optimizer( request, entry.runtime_data, timings=timings ) + self._remember_historical_price_series(entry, request) planner_output = self._planner_output_from_result( request, planner_result, timings=timings ) @@ -468,6 +491,29 @@ async def async_build_planner_input_export(self) -> dict[str, Any]: request, _timings = await self._async_build_planning_request(entry) return request + def _remember_historical_price_series( + self, + entry: ConfigEntry, + request: dict[str, Any], + ) -> None: + """Send successful planner price inputs to historical tracking.""" + historical_tracker = getattr(entry.runtime_data, "historical_tracker", None) + if historical_tracker is None: + return + optimizer_params = request["optimizer_params"] + try: + historical_tracker.remember_price_series( + start_at=request["window"].start_at, + slot_minutes=int(request["slot_minutes"]), + import_prices=list(optimizer_params["grid_import_price_per_kwh"]), + export_prices=list(optimizer_params["grid_export_price_per_kwh"]), + ) + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "Failed to retain historical planner prices (entry_id=%s): %s", + self._entry_id, + err, + ) def _sync_source_issues(self, entry: ConfigEntry) -> None: """Publish the current source issue set to the repairs dashboard.""" diff --git a/custom_components/wattplan/entry_setup.py b/custom_components/wattplan/entry_setup.py index 99e56b7..7027b61 100644 --- a/custom_components/wattplan/entry_setup.py +++ b/custom_components/wattplan/entry_setup.py @@ -7,11 +7,18 @@ import logging from typing import Any -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant - -from .const import CONF_ACTION_EMISSION_ENABLED, CONF_PLANNING_ENABLED, CONF_SLOT_MINUTES, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, callback + +from .const import ( + CONF_ACTION_EMISSION_ENABLED, + CONF_HISTORICAL_COST_TRACKING_ENABLED, + CONF_PLANNING_ENABLED, + CONF_SLOT_MINUTES, + DOMAIN, +) from .coordinator import CycleTrigger, WattPlanCoordinator +from .historical_cost.tracker import HistoricalCostTracker from .runtime import WattPlanConfigEntry, WattPlanRuntimeData, mark_runtime_updated from .services import SERVICE_SPECS @@ -65,6 +72,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattPlanConfigEntry) -> coordinator=coordinator, last_run_at=datetime.now(tz=UTC), ) + if bool(entry.options.get(CONF_HISTORICAL_COST_TRACKING_ENABLED, False)): + tracker = HistoricalCostTracker( + hass, + entry, + slot_minutes=int(entry.data[CONF_SLOT_MINUTES]), + ) + entry.runtime_data.historical_tracker = tracker + await tracker.async_start() + + @callback + def _async_flush_history_on_stop(_event: Event) -> None: + hass.async_create_task(tracker.async_shutdown()) + + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + _async_flush_history_on_stop, + ) + ) + had_snapshot = await coordinator.async_restore_snapshot() entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -75,6 +102,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: WattPlanConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WattPlanConfigEntry) -> bool: """Unload a config entry.""" + if entry.runtime_data.historical_tracker is not None: + await entry.runtime_data.historical_tracker.async_shutdown() await entry.runtime_data.coordinator.async_shutdown() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: diff --git a/custom_components/wattplan/flows/main.py b/custom_components/wattplan/flows/main.py index 29b7ae4..ebe3f0a 100644 --- a/custom_components/wattplan/flows/main.py +++ b/custom_components/wattplan/flows/main.py @@ -4,6 +4,10 @@ from typing import Any +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfEnergy +from homeassistant.helpers import entity_registry as er + from .common import _normalize_name, _subentry_display_title, _subentry_name from .forms import ( _battery_form_defaults, @@ -18,7 +22,15 @@ from .state import SourceFlowState from .source_shared import ( CONF_ACTION_EMISSION_ENABLED, + CONF_HISTORICAL_COST_TRACKING_ENABLED, + CONF_HISTORICAL_GRID_EXPORT_SENSOR, + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_PV_SENSOR, + CONF_HISTORICAL_SIMULATE_NO_BATTERY, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, + CONF_HISTORICAL_USAGE_SENSOR, CONF_HOURS_TO_PLAN, + CONF_CONFIG_ENTRY_ID, CONF_NAME, CONF_OPTIMIZER_PROFILE, CONF_PLANNING_ENABLED, @@ -37,6 +49,9 @@ OPTIMIZER_PROFILE_BALANCED, OptionsFlowWithReload, SOURCE_MODE_NOT_USED, + SOURCE_MODE_BUILT_IN, + SOURCE_MODE_ENERGY_PROVIDER, + SOURCE_MODE_ENTITY_ADAPTER, SUBENTRY_TYPE_BATTERY, SUBENTRY_TYPE_COMFORT, SUBENTRY_TYPE_OPTIONAL, @@ -55,6 +70,211 @@ selector, vol, ) +from ..historical_cost.tracker import validate_energy_sensor +from ..source_providers import CONF_WATTPLAN_ENTITY_ID, source_mode, source_providers + + +ENERGY_STATE_CLASSES = {"total", "total_increasing"} + + +def _historical_energy_selector() -> selector.EntitySelector: + """Return selector for cumulative kWh sensors.""" + return selector.EntitySelector( + selector.EntitySelectorConfig(domain=["sensor"], device_class=["energy"]) + ) + + +def _historical_costs_schema(defaults: dict[str, Any]) -> vol.Schema: + """Build the historical cost intro schema.""" + return vol.Schema( + { + vol.Required( + CONF_HISTORICAL_COST_TRACKING_ENABLED, + default=bool( + defaults.get(CONF_HISTORICAL_COST_TRACKING_ENABLED, False) + ), + ): selector.BooleanSelector(), + } + ) + + +def _historical_costs_settings_schema(defaults: dict[str, Any]) -> vol.Schema: + """Build the historical cost settings schema.""" + + def optional_entity(field: str) -> vol.Optional: + default = defaults.get(field) + if default: + return vol.Optional(field, default=default) + return vol.Optional(field) + + return vol.Schema( + { + optional_entity( + CONF_HISTORICAL_GRID_IMPORT_SENSOR + ): _historical_energy_selector(), + optional_entity( + CONF_HISTORICAL_GRID_EXPORT_SENSOR + ): _historical_energy_selector(), + optional_entity( + CONF_HISTORICAL_USAGE_SENSOR + ): _historical_energy_selector(), + optional_entity(CONF_HISTORICAL_PV_SENSOR): _historical_energy_selector(), + vol.Required( + CONF_HISTORICAL_SIMULATE_NO_BATTERY, + default=bool(defaults.get(CONF_HISTORICAL_SIMULATE_NO_BATTERY, True)), + ): selector.BooleanSelector(), + vol.Required( + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, + default=bool( + defaults.get(CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, True) + ), + ): selector.BooleanSelector(), + } + ) + + +def _normalize_historical_options(user_input: dict[str, Any]) -> dict[str, Any]: + """Normalize historical cost options for storage.""" + normalized = dict(user_input) + for key in ( + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_GRID_EXPORT_SENSOR, + CONF_HISTORICAL_USAGE_SENSOR, + CONF_HISTORICAL_PV_SENSOR, + ): + if not normalized.get(key): + normalized[key] = None + return normalized + + +def _historical_status_label(options: dict[str, Any]) -> str: + """Return a user-facing historical tracking status label.""" + if options.get(CONF_HISTORICAL_COST_TRACKING_ENABLED, False): + return "Enabled" + return "Disabled" + + +def _is_cumulative_energy_sensor(hass, entity_id: str | None) -> bool: + """Return whether an entity is a currently loaded cumulative kWh sensor.""" + if not entity_id or not str(entity_id).startswith("sensor."): + return False + state = hass.states.get(str(entity_id)) + if state is None: + return False + device_class = state.attributes.get("device_class") + if device_class not in {SensorDeviceClass.ENERGY, "energy"}: + return False + unit = state.attributes.get("unit_of_measurement") + if unit not in {UnitOfEnergy.KILO_WATT_HOUR, "kWh"}: + return False + return state.attributes.get("state_class") in ENERGY_STATE_CLASSES + + +def _is_power_sensor(hass, entity_id: str | None) -> bool: + """Return whether an entity is a currently loaded power sensor.""" + if not entity_id or not str(entity_id).startswith("sensor."): + return False + state = hass.states.get(str(entity_id)) + if state is None: + return False + return state.attributes.get("device_class") in {SensorDeviceClass.POWER, "power"} + + +def _single_candidate(candidates: set[str]) -> str | None: + """Return one candidate only when discovery found an unambiguous match.""" + if len(candidates) != 1: + return None + return next(iter(candidates)) + + +def _energy_sibling_for_entity(hass, entity_id: str) -> str | None: + """Return the only cumulative energy sensor on the same device, if any.""" + registry = er.async_get(hass) + entry = registry.async_get(entity_id) + if entry is None or entry.device_id is None: + return None + candidates = { + sibling.entity_id + for sibling in er.async_entries_for_device( + registry, entry.device_id, include_disabled_entities=False + ) + if sibling.entity_id != entity_id + and _is_cumulative_energy_sensor(hass, sibling.entity_id) + } + return _single_candidate(candidates) + + +def _energy_entity_for_config_entry(hass, config_entry_id: str | None) -> str | None: + """Return the only cumulative energy sensor owned by a config entry, if any.""" + if not config_entry_id: + return None + registry = er.async_get(hass) + candidates = { + entry.entity_id + for entry in er.async_entries_for_config_entry(registry, str(config_entry_id)) + if _is_cumulative_energy_sensor(hass, entry.entity_id) + } + return _single_candidate(candidates) + + +def _energy_entity_for_source_entity(hass, entity_id: str | None) -> str | None: + """Return a historical meter candidate from a configured source entity.""" + if not entity_id: + return None + entity_id = str(entity_id) + if _is_cumulative_energy_sensor(hass, entity_id): + return entity_id + if _is_power_sensor(hass, entity_id): + return _energy_sibling_for_entity(hass, entity_id) + return None + + +def _energy_entity_for_source_provider( + hass, + provider_config: dict[str, Any], +) -> str | None: + """Return a historical meter candidate from one source provider config.""" + mode = source_mode(provider_config) + if mode in {SOURCE_MODE_BUILT_IN, SOURCE_MODE_ENTITY_ADAPTER}: + return _energy_entity_for_source_entity( + hass, provider_config.get(CONF_WATTPLAN_ENTITY_ID) + ) + if mode == SOURCE_MODE_ENERGY_PROVIDER: + return _energy_entity_for_config_entry( + hass, provider_config.get(CONF_CONFIG_ENTRY_ID) + ) + return None + + +def _energy_entity_for_source(hass, source_config: dict[str, Any] | None) -> str | None: + """Return a historical meter candidate from a complete source config.""" + if not isinstance(source_config, dict): + return None + candidates = { + candidate + for provider_config in source_providers(source_config) + if isinstance(provider_config, dict) + for candidate in [_energy_entity_for_source_provider(hass, provider_config)] + if candidate + } + return _single_candidate(candidates) + + +def _discovered_historical_meter_defaults(hass, data: dict[str, Any]) -> dict[str, str]: + """Return unambiguous historical meter defaults from configured sources.""" + sources = data.get(CONF_SOURCES, {}) + if not isinstance(sources, dict): + return {} + + defaults: dict[str, str] = {} + usage = _energy_entity_for_source(hass, sources.get(CONF_SOURCE_USAGE)) + if usage: + defaults[CONF_HISTORICAL_USAGE_SENSOR] = usage + pv = _energy_entity_for_source(hass, sources.get(CONF_SOURCE_PV)) + if pv: + defaults[CONF_HISTORICAL_PV_SENSOR] = pv + return defaults + CONF_ACCEPT_MANUAL_SCHEDULING = "accept_manual_scheduling" @@ -475,6 +695,7 @@ class WattPlanOptionsFlow(_SharedSourceFlow, OptionsFlowWithReload): _data: dict[str, Any] _options: dict[str, Any] + _historical_suggest_discovered: bool _pending_timer_options: dict[str, Any] | None _selected_subentry_id: str | None _source_state: SourceFlowState @@ -488,6 +709,7 @@ def __init__(self, config_entry: ConfigEntry) -> None: self._options.setdefault( CONF_OPTIMIZER_PROFILE, OPTIMIZER_PROFILE_BALANCED ) + self._historical_suggest_discovered = False self._pending_timer_options = None self._selected_subentry_id = None self._source_state = SourceFlowState() @@ -503,6 +725,7 @@ async def async_step_init( "source_usage", "source_pv", "source_export_price", + "historical_costs", ] return self.async_show_menu( @@ -650,6 +873,89 @@ async def async_step_planner_timers_warning_both( """Warn when all automatic scheduler behavior is disabled.""" return await self._async_handle_planner_timers_warning(user_input) + async def async_step_historical_costs( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show historical cost tracking intro and enabled toggle.""" + if user_input is not None: + was_enabled = bool( + self._options.get(CONF_HISTORICAL_COST_TRACKING_ENABLED, False) + ) + enabled = bool(user_input[CONF_HISTORICAL_COST_TRACKING_ENABLED]) + self._historical_suggest_discovered = enabled and not was_enabled + self._options[CONF_HISTORICAL_COST_TRACKING_ENABLED] = enabled + if not enabled: + self.hass.config_entries.async_update_entry( + self.config_entry, + options=self._options, + ) + return self.async_create_entry(title="", data=None) + return await self.async_step_historical_costs_settings() + + return self.async_show_form( + step_id="historical_costs", + data_schema=self.add_suggested_values_to_schema( + _historical_costs_schema(self._options), + user_input or {}, + ), + description_placeholders={ + "current_status": _historical_status_label(self._options), + }, + ) + + async def async_step_historical_costs_settings( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure historical cost tracking settings.""" + errors: dict[str, str] = {} + defaults = self._historical_costs_settings_defaults() + if user_input is not None: + normalized = _normalize_historical_options(user_input) + normalized[CONF_HISTORICAL_COST_TRACKING_ENABLED] = True + for key in ( + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_USAGE_SENSOR, + ): + if not normalized.get(key): + errors[key] = "required" + for key in ( + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_GRID_EXPORT_SENSOR, + CONF_HISTORICAL_USAGE_SENSOR, + CONF_HISTORICAL_PV_SENSOR, + ): + entity_id = normalized.get(key) + if entity_id and not validate_energy_sensor(self.hass, entity_id): + errors[key] = "invalid_energy_sensor" + if not errors: + self._options.update(normalized) + self.hass.config_entries.async_update_entry( + self.config_entry, + options=self._options, + ) + return self.async_create_entry(title="", data=None) + + return self.async_show_form( + step_id="historical_costs_settings", + data_schema=self.add_suggested_values_to_schema( + _historical_costs_settings_schema(defaults), + user_input or {}, + ), + errors=errors, + ) + + def _historical_costs_settings_defaults(self) -> dict[str, Any]: + """Return defaults for the historical costs settings form.""" + defaults = dict(self._options) + if not self._historical_suggest_discovered: + return defaults + for key, value in _discovered_historical_meter_defaults( + self.hass, self._data + ).items(): + if not defaults.get(key): + defaults[key] = value + return defaults + async def async_step_battery_entities( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/custom_components/wattplan/flows/source_shared.py b/custom_components/wattplan/flows/source_shared.py index 3cd8c9a..f510cd0 100644 --- a/custom_components/wattplan/flows/source_shared.py +++ b/custom_components/wattplan/flows/source_shared.py @@ -60,6 +60,13 @@ CONF_EXPECTED_POWER_KW, CONF_FIXUP_PROFILE, CONF_HISTORY_DAYS, + CONF_HISTORICAL_COST_TRACKING_ENABLED, + CONF_HISTORICAL_GRID_EXPORT_SENSOR, + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_PV_SENSOR, + CONF_HISTORICAL_SIMULATE_NO_BATTERY, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, + CONF_HISTORICAL_USAGE_SENSOR, CONF_HOURS_TO_PLAN, CONF_MAX_CHARGE_KW, CONF_MAX_CONSECUTIVE_OFF_MINUTES, diff --git a/custom_components/wattplan/historical_cost/__init__.py b/custom_components/wattplan/historical_cost/__init__.py new file mode 100644 index 0000000..24b2ee4 --- /dev/null +++ b/custom_components/wattplan/historical_cost/__init__.py @@ -0,0 +1,2 @@ +"""Historical cost tracking support for WattPlan.""" + diff --git a/custom_components/wattplan/historical_cost/models.py b/custom_components/wattplan/historical_cost/models.py new file mode 100644 index 0000000..1d4a39b --- /dev/null +++ b/custom_components/wattplan/historical_cost/models.py @@ -0,0 +1,102 @@ +"""Shared models and constants for historical cost tracking.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any + +RETENTION_DAYS = 60 +STORE_VERSION = 1 +SAVE_DELAY_SECONDS = 10 + +SCENARIO_ACTUAL = "actual" +SCENARIO_NO_BATTERY = "no_battery" +SCENARIO_SELF_CONSUMPTION = "self_consumption" + +PERIOD_TODAY = "today" +PERIOD_THIS_MONTH = "this_month" + +FLAG_GAP = 1 +FLAG_MISSING_IMPORT_PRICE = 2 +FLAG_MISSING_EXPORT_PRICE = 4 +FLAG_MISSING_METER = 8 +FLAG_METER_RESET = 16 +FLAG_SELF_CONSUMPTION_UNAVAILABLE = 32 + +DAY_ARRAY_KEYS: tuple[str, ...] = ( + "starts", + "import_price", + "export_price", + "grid_import", + "grid_export", + "usage", + "pv", + "self_consumption_grid_import", + "self_consumption_grid_export", + "flags", +) + + +class HistoricalMetric(StrEnum): + """Supported historical entity metric kinds.""" + + COST = "cost" + SAVINGS_VS_NO_BATTERY = "savings_vs_no_battery" + SAVINGS_VS_SELF_CONSUMPTION = "savings_vs_self_consumption" + + +@dataclass(frozen=True, slots=True) +class HistoricalSensorDescription: + """Description for one historical cost sensor.""" + + key: str + metric: HistoricalMetric + period: str + scenario: str | None + name: str + enabled_default: bool + + +@dataclass(frozen=True, slots=True) +class SlotRecord: + """One retained historical slot.""" + + start: datetime + import_price: float | None + export_price: float | None + grid_import: float | None + grid_export: float | None + usage: float | None + pv: float | None + flags: int = 0 + self_consumption_grid_import: float | None = None + self_consumption_grid_export: float | None = None + + +def default_store_payload( + *, slot_minutes: int, currency: str, started_at: datetime +) -> dict[str, Any]: + """Return an empty store payload for the current schema.""" + return { + "version": STORE_VERSION, + "slot_minutes": int(slot_minutes), + "currency": currency, + "tracking_started_at": started_at.isoformat(), + "last_processed_slot": None, + "last_meter_values": {}, + "meter_config": {}, + "price_cache": {}, + "days": {}, + "simulation_state": { + "self_consumption": { + "batteries": {}, + } + }, + } + + +def empty_day_payload() -> dict[str, list[Any]]: + """Return an empty day payload with all expected arrays.""" + return {key: [] for key in DAY_ARRAY_KEYS} diff --git a/custom_components/wattplan/historical_cost/simulations.py b/custom_components/wattplan/historical_cost/simulations.py new file mode 100644 index 0000000..f3024db --- /dev/null +++ b/custom_components/wattplan/historical_cost/simulations.py @@ -0,0 +1,125 @@ +"""Pure historical cost simulation logic.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class BatterySimulationConfig: + """Battery settings used by historical self-consumption simulation.""" + + subentry_id: str + minimum_kwh: float + capacity_kwh: float + max_charge_kwh: float + max_discharge_kwh: float + charge_efficiency: float + discharge_efficiency: float + can_charge_from_pv: bool + + +@dataclass(frozen=True, slots=True) +class SelfConsumptionSimulationResult: + """Result of one self-consumption simulation slot.""" + + grid_import: float + grid_export: float + soc_by_battery: dict[str, float] + + +def actual_cost( + *, + grid_import: float, + grid_export: float, + import_price: float, + export_price: float, +) -> float: + """Return measured net cost for one slot.""" + return (grid_import * import_price) - (grid_export * export_price) + + +def no_battery_flows(*, usage: float, pv: float) -> tuple[float, float]: + """Return grid import/export if no battery existed.""" + return max(usage - pv, 0.0), max(pv - usage, 0.0) + + +def no_battery_cost( + *, + usage: float, + pv: float, + import_price: float, + export_price: float, +) -> float: + """Return no-battery scenario cost for one slot.""" + grid_import, grid_export = no_battery_flows(usage=usage, pv=pv) + return actual_cost( + grid_import=grid_import, + grid_export=grid_export, + import_price=import_price, + export_price=export_price, + ) + + +def simulate_self_consumption_slot( + *, + usage: float, + pv: float, + batteries: list[BatterySimulationConfig], + soc_by_battery: dict[str, float], +) -> SelfConsumptionSimulationResult: + """Simulate one PV-first self-consumption slot.""" + surplus = max(pv - usage, 0.0) + deficit = max(usage - pv, 0.0) + next_soc = dict(soc_by_battery) + + for battery in batteries: + if surplus <= 0.0 or not battery.can_charge_from_pv: + continue + current_soc = _clamp_soc(next_soc.get(battery.subentry_id, battery.minimum_kwh), battery) + efficiency = max(battery.charge_efficiency, 0.000001) + capacity_room_input = max(battery.capacity_kwh - current_soc, 0.0) / efficiency + charge_input = min(surplus, battery.max_charge_kwh, capacity_room_input) + if charge_input <= 0.0: + next_soc[battery.subentry_id] = current_soc + continue + next_soc[battery.subentry_id] = min( + battery.capacity_kwh, + current_soc + (charge_input * efficiency), + ) + surplus -= charge_input + + for battery in batteries: + if deficit <= 0.0: + break + current_soc = _clamp_soc(next_soc.get(battery.subentry_id, battery.minimum_kwh), battery) + efficiency = max(battery.discharge_efficiency, 0.000001) + available_output = max(current_soc - battery.minimum_kwh, 0.0) * efficiency + max_output = battery.max_discharge_kwh + output = min(deficit, available_output, max_output) + if output <= 0.0: + next_soc[battery.subentry_id] = current_soc + continue + next_soc[battery.subentry_id] = max( + battery.minimum_kwh, + current_soc - (output / efficiency), + ) + deficit -= output + + for battery in batteries: + if battery.subentry_id in next_soc: + next_soc[battery.subentry_id] = _clamp_soc( + next_soc[battery.subentry_id], + battery, + ) + + return SelfConsumptionSimulationResult( + grid_import=max(deficit, 0.0), + grid_export=max(surplus, 0.0), + soc_by_battery=next_soc, + ) + + +def _clamp_soc(value: float, battery: BatterySimulationConfig) -> float: + """Clamp a battery SoC to configured bounds.""" + return max(battery.minimum_kwh, min(battery.capacity_kwh, float(value))) diff --git a/custom_components/wattplan/historical_cost/store.py b/custom_components/wattplan/historical_cost/store.py new file mode 100644 index 0000000..0d80eef --- /dev/null +++ b/custom_components/wattplan/historical_cost/store.py @@ -0,0 +1,546 @@ +"""Home Assistant Store wrapper for historical cost tracking.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +import math +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util + +from ..const import DOMAIN +from .models import ( + DAY_ARRAY_KEYS, + FLAG_GAP, + FLAG_MISSING_EXPORT_PRICE, + FLAG_MISSING_IMPORT_PRICE, + FLAG_MISSING_METER, + FLAG_METER_RESET, + FLAG_SELF_CONSUMPTION_UNAVAILABLE, + HistoricalMetric, + PERIOD_THIS_MONTH, + PERIOD_TODAY, + RETENTION_DAYS, + SAVE_DELAY_SECONDS, + SCENARIO_ACTUAL, + SCENARIO_NO_BATTERY, + SCENARIO_SELF_CONSUMPTION, + STORE_VERSION, + SlotRecord, + default_store_payload, + empty_day_payload, +) +from .simulations import actual_cost, no_battery_cost + +BAD_SLOT_FLAGS = ( + FLAG_GAP + | FLAG_MISSING_IMPORT_PRICE + | FLAG_MISSING_EXPORT_PRICE + | FLAG_MISSING_METER + | FLAG_METER_RESET +) + + +@dataclass(frozen=True, slots=True) +class HistoricalPeriodSummary: + """Aggregated value and attributes for one historical sensor.""" + + value: float | None + tracking_started_at: str | None + last_complete_slot: str | None + slots: int + missing_slots: int + period_start: str + period_end: str + scenario: str | None + + +class HistoricalCostStore: + """Thin wrapper around Home Assistant Store for one config entry.""" + + def __init__( + self, + hass: HomeAssistant, + *, + entry_id: str, + slot_minutes: int, + currency: str, + ) -> None: + """Initialize the store wrapper.""" + self.hass = hass + self.entry_id = entry_id + self.slot_minutes = int(slot_minutes) + self.currency = currency + self._store = Store[dict[str, Any]]( + hass, + STORE_VERSION, + f"{DOMAIN}.history.{entry_id}", + private=True, + ) + self.data = default_store_payload( + slot_minutes=slot_minutes, + currency=currency, + started_at=datetime.now(tz=UTC), + ) + self._loaded = False + + async def async_load(self) -> None: + """Load, migrate, and prune stored historical data.""" + payload = await self._store.async_load() + if not isinstance(payload, dict): + payload = default_store_payload( + slot_minutes=self.slot_minutes, + currency=self.currency, + started_at=datetime.now(tz=UTC), + ) + self.data = self._migrate(payload) + self.prune(datetime.now(tz=UTC)) + self._loaded = True + + def mark_dirty(self) -> None: + """Schedule a delayed coalesced save.""" + self.prune(datetime.now(tz=UTC)) + self._store.async_delay_save(lambda: self.data, SAVE_DELAY_SECONDS) + + async def async_flush(self) -> None: + """Persist the current in-memory payload immediately.""" + if self._loaded: + self.prune(datetime.now(tz=UTC)) + await self._store.async_save(self.data) + + def update_metadata( + self, + *, + last_processed_slot: datetime | None = None, + last_meter_values: dict[str, float | None] | None = None, + meter_config: dict[str, Any] | None = None, + ) -> None: + """Update persisted cursors and configuration metadata.""" + if last_processed_slot is not None: + self.data["last_processed_slot"] = _utc_iso(last_processed_slot) + if last_meter_values is not None: + self.data["last_meter_values"] = dict(last_meter_values) + if meter_config is not None: + self.data["meter_config"] = dict(meter_config) + self.mark_dirty() + + def last_processed_slot(self) -> datetime | None: + """Return the last processed slot cursor.""" + value = self.data.get("last_processed_slot") + if not isinstance(value, str) or not value: + return None + parsed = dt_util.parse_datetime(value) + if parsed is None: + return None + return parsed.astimezone(UTC) + + def last_meter_values(self) -> dict[str, float | None]: + """Return the last meter cursor payload.""" + raw = self.data.get("last_meter_values") + if not isinstance(raw, dict): + return {} + values: dict[str, float | None] = {} + for key, value in raw.items(): + if value is None: + values[str(key)] = None + continue + try: + values[str(key)] = float(value) + except (TypeError, ValueError): + values[str(key)] = None + return values + + def simulation_soc(self) -> dict[str, float]: + """Return persisted self-consumption battery SoC values.""" + state = self.data.setdefault("simulation_state", {}).setdefault( + "self_consumption", + {}, + ) + batteries = state.setdefault("batteries", {}) + if not isinstance(batteries, dict): + state["batteries"] = {} + batteries = state["batteries"] + result: dict[str, float] = {} + for subentry_id, payload in batteries.items(): + if not isinstance(payload, dict): + continue + try: + result[str(subentry_id)] = float(payload["soc_kwh"]) + except (KeyError, TypeError, ValueError): + continue + return result + + def update_simulation_soc(self, soc_by_battery: dict[str, float]) -> None: + """Persist self-consumption battery SoC state.""" + state = self.data.setdefault("simulation_state", {}).setdefault( + "self_consumption", + {}, + ) + state["batteries"] = { + subentry_id: {"soc_kwh": float(soc)} + for subentry_id, soc in soc_by_battery.items() + } + + def remember_price_series( + self, + *, + start_at: datetime, + slot_minutes: int, + import_prices: list[float], + export_prices: list[float], + ) -> None: + """Retain normalized planner price values by UTC slot start.""" + if int(slot_minutes) != self.slot_minutes: + return + cache = self.data.setdefault("price_cache", {}) + if not isinstance(cache, dict): + cache = {} + self.data["price_cache"] = cache + + interval = timedelta(minutes=self.slot_minutes) + changed = False + for index, raw_import_price in enumerate(import_prices): + import_price = _finite_float(raw_import_price) + if import_price is None: + continue + slot_start = start_at.astimezone(UTC) + (interval * index) + export_price = _finite_float( + export_prices[index] if index < len(export_prices) else 0.0 + ) + if export_price is None: + continue + cache[_utc_iso(slot_start)] = { + "import_price": import_price, + "export_price": export_price, + } + changed = True + if changed: + self.mark_dirty() + + def cached_price(self, slot_start: datetime, kind: str) -> float | None: + """Return a retained planner price for one UTC slot, if available.""" + cache = self.data.get("price_cache") + if not isinstance(cache, dict): + return None + entry = cache.get(_utc_iso(slot_start)) + if not isinstance(entry, dict): + return None + return _finite_float(entry.get(f"{kind}_price")) + + def append_slot(self, record: SlotRecord) -> None: + """Append one slot fact record to retained history.""" + local_day = self._local_date(record.start).isoformat() + days = self.data.setdefault("days", {}) + day_payload = days.setdefault(local_day, empty_day_payload()) + for key in DAY_ARRAY_KEYS: + day_payload.setdefault(key, []) + day_payload["starts"].append(_utc_iso(record.start)) + day_payload["import_price"].append(record.import_price) + day_payload["export_price"].append(record.export_price) + day_payload["grid_import"].append(record.grid_import) + day_payload["grid_export"].append(record.grid_export) + day_payload["usage"].append(record.usage) + day_payload["pv"].append(record.pv) + day_payload["self_consumption_grid_import"].append( + record.self_consumption_grid_import + ) + day_payload["self_consumption_grid_export"].append( + record.self_consumption_grid_export + ) + day_payload["flags"].append(int(record.flags)) + self.data["last_processed_slot"] = _utc_iso(record.start) + self.mark_dirty() + + def prune(self, now: datetime) -> None: + """Drop retained local days and cached prices outside the retention window.""" + days = self.data.setdefault("days", {}) + if not isinstance(days, dict): + self.data["days"] = {} + cutoff = self._local_date(now) - timedelta(days=RETENTION_DAYS - 1) + if isinstance(days, dict): + for key in list(days): + try: + day = date.fromisoformat(str(key)) + except ValueError: + del days[key] + continue + if day < cutoff: + del days[key] + + cache = self.data.setdefault("price_cache", {}) + if not isinstance(cache, dict): + self.data["price_cache"] = {} + return + for key in list(cache): + parsed = dt_util.parse_datetime(str(key)) + if parsed is None: + del cache[key] + continue + if self._local_date(parsed.astimezone(UTC)) < cutoff: + del cache[key] + + def summary( + self, + *, + metric: HistoricalMetric, + period: str, + scenario: str | None, + now: datetime | None = None, + ) -> HistoricalPeriodSummary: + """Return an aggregate summary for one sensor.""" + now = now or datetime.now(tz=UTC) + period_start, period_end = self._period_bounds(period, now) + records = list(self._records_between(period_start, period_end)) + missing_slots = sum(1 for record in records if int(record.flags) != 0) + values: list[float] = [] + for record in records: + value = self._record_value(record, metric=metric, scenario=scenario) + if value is not None: + values.append(value) + total = round(sum(values), 4) if values else None + if total is None and not records and self._tracking_intersects_period( + period_start, period_end + ): + total = 0.0 + return HistoricalPeriodSummary( + value=total, + tracking_started_at=self._tracking_started_at(), + last_complete_slot=self.data.get("last_processed_slot"), + slots=len(records), + missing_slots=missing_slots, + period_start=period_start.isoformat(), + period_end=period_end.isoformat(), + scenario=scenario, + ) + + def _record_value( + self, + record: SlotRecord, + *, + metric: HistoricalMetric, + scenario: str | None, + ) -> float | None: + if metric is HistoricalMetric.COST: + if scenario is None: + return None + return self._scenario_cost(record, scenario) + actual = self._scenario_cost(record, SCENARIO_ACTUAL) + if actual is None: + return None + if metric is HistoricalMetric.SAVINGS_VS_NO_BATTERY: + baseline = self._scenario_cost(record, SCENARIO_NO_BATTERY) + else: + baseline = self._scenario_cost(record, SCENARIO_SELF_CONSUMPTION) + if baseline is None: + return None + return baseline - actual + + def _scenario_cost(self, record: SlotRecord, scenario: str) -> float | None: + if record.flags & BAD_SLOT_FLAGS: + return None + if record.import_price is None or record.export_price is None: + return None + if scenario == SCENARIO_ACTUAL: + if record.grid_import is None or record.grid_export is None: + return None + return actual_cost( + grid_import=record.grid_import, + grid_export=record.grid_export, + import_price=record.import_price, + export_price=record.export_price, + ) + if scenario == SCENARIO_NO_BATTERY: + if record.usage is None or record.pv is None: + return None + return no_battery_cost( + usage=record.usage, + pv=record.pv, + import_price=record.import_price, + export_price=record.export_price, + ) + if scenario == SCENARIO_SELF_CONSUMPTION: + if record.flags & FLAG_SELF_CONSUMPTION_UNAVAILABLE: + return None + if ( + record.self_consumption_grid_import is None + or record.self_consumption_grid_export is None + ): + return None + return actual_cost( + grid_import=record.self_consumption_grid_import, + grid_export=record.self_consumption_grid_export, + import_price=record.import_price, + export_price=record.export_price, + ) + return None + + def _records_between( + self, start: datetime, end: datetime + ) -> list[SlotRecord]: + records: list[SlotRecord] = [] + days = self.data.get("days", {}) + if not isinstance(days, dict): + return records + for day_payload in days.values(): + if not isinstance(day_payload, dict): + continue + starts = day_payload.get("starts", []) + if not isinstance(starts, list): + continue + for index, raw_start in enumerate(starts): + parsed = dt_util.parse_datetime(str(raw_start)) + if parsed is None: + continue + slot_start = parsed.astimezone(UTC) + if slot_start < start or slot_start >= end: + continue + records.append(self._record_from_day(day_payload, index, slot_start)) + records.sort(key=lambda record: record.start) + return records + + def _record_from_day( + self, day_payload: dict[str, Any], index: int, start: datetime + ) -> SlotRecord: + return SlotRecord( + start=start, + import_price=_optional_float_at(day_payload, "import_price", index), + export_price=_optional_float_at(day_payload, "export_price", index), + grid_import=_optional_float_at(day_payload, "grid_import", index), + grid_export=_optional_float_at(day_payload, "grid_export", index), + usage=_optional_float_at(day_payload, "usage", index), + pv=_optional_float_at(day_payload, "pv", index), + self_consumption_grid_import=_optional_float_at( + day_payload, + "self_consumption_grid_import", + index, + ), + self_consumption_grid_export=_optional_float_at( + day_payload, + "self_consumption_grid_export", + index, + ), + flags=int(_optional_float_at(day_payload, "flags", index) or 0), + ) + + def _period_bounds( + self, period: str, now: datetime + ) -> tuple[datetime, datetime]: + local_now = dt_util.as_local(now) + if period == PERIOD_THIS_MONTH: + local_start = local_now.replace( + day=1, + hour=0, + minute=0, + second=0, + microsecond=0, + ) + if local_start.month == 12: + local_end = local_start.replace( + year=local_start.year + 1, + month=1, + ) + else: + local_end = local_start.replace(month=local_start.month + 1) + else: + local_start = local_now.replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + local_end = local_start + timedelta(days=1) + return local_start.astimezone(UTC), local_end.astimezone(UTC) + + def _local_date(self, value: datetime) -> date: + return dt_util.as_local(value).date() + + def _tracking_started_at(self) -> str | None: + value = self.data.get("tracking_started_at") + return str(value) if value else None + + def _tracking_intersects_period(self, period_start: datetime, period_end: datetime) -> bool: + """Return whether the store has started tracking within this aggregate period.""" + for raw in ( + self.data.get("tracking_started_at"), + self.data.get("last_processed_slot"), + ): + if not isinstance(raw, str) or not raw: + continue + parsed = dt_util.parse_datetime(raw) + if parsed is None: + continue + tracked_at = parsed.astimezone(UTC) + if period_start <= tracked_at < period_end: + return True + return False + + def _migrate(self, payload: dict[str, Any]) -> dict[str, Any]: + migrated = default_store_payload( + slot_minutes=self.slot_minutes, + currency=self.currency, + started_at=datetime.now(tz=UTC), + ) + if int(payload.get("version", 0) or 0) <= STORE_VERSION: + migrated.update(payload) + migrated["version"] = STORE_VERSION + migrated["slot_minutes"] = int(migrated.get("slot_minutes") or self.slot_minutes) + migrated["currency"] = str(migrated.get("currency") or self.currency) + if not isinstance(migrated.get("last_meter_values"), dict): + migrated["last_meter_values"] = {} + if not isinstance(migrated.get("meter_config"), dict): + migrated["meter_config"] = {} + if not isinstance(migrated.get("price_cache"), dict): + migrated["price_cache"] = {} + if not isinstance(migrated.get("days"), dict): + migrated["days"] = {} + if not isinstance(migrated.get("simulation_state"), dict): + migrated["simulation_state"] = {} + for slot_key, price_payload in list(migrated["price_cache"].items()): + if not isinstance(price_payload, dict): + del migrated["price_cache"][slot_key] + continue + import_price = _finite_float(price_payload.get("import_price")) + export_price = _finite_float(price_payload.get("export_price")) + if import_price is None or export_price is None: + del migrated["price_cache"][slot_key] + continue + price_payload["import_price"] = import_price + price_payload["export_price"] = export_price + for day_key, day_payload in list(migrated["days"].items()): + if not isinstance(day_payload, dict): + del migrated["days"][day_key] + continue + for array_key in DAY_ARRAY_KEYS: + if not isinstance(day_payload.get(array_key), list): + day_payload[array_key] = [] + return migrated + + +def _optional_float_at(payload: dict[str, Any], key: str, index: int) -> float | None: + values = payload.get(key, []) + if not isinstance(values, list) or index >= len(values): + return None + value = values[index] + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _finite_float(value: Any) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed): + return None + return parsed + + +def _utc_iso(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/custom_components/wattplan/historical_cost/tracker.py b/custom_components/wattplan/historical_cost/tracker.py new file mode 100644 index 0000000..e34d1b0 --- /dev/null +++ b/custom_components/wattplan/historical_cost/tracker.py @@ -0,0 +1,526 @@ +"""Runtime tracker for historical cost slots.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import logging +from typing import Any, Callable + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfEnergy +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_track_point_in_utc_time + +from ..const import ( + CONF_CAN_CHARGE_FROM_PV, + CONF_CAPACITY_KWH, + CONF_CHARGE_EFFICIENCY, + CONF_DISCHARGE_EFFICIENCY, + CONF_HISTORICAL_GRID_EXPORT_SENSOR, + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_PV_SENSOR, + CONF_HISTORICAL_SIMULATE_NO_BATTERY, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, + CONF_HISTORICAL_USAGE_SENSOR, + CONF_MAX_CHARGE_KW, + CONF_MAX_DISCHARGE_KW, + CONF_MINIMUM_KWH, + CONF_SOC_SOURCE, + CONF_SOURCE_EXPORT_PRICE, + CONF_SOURCE_IMPORT_PRICE, + CONF_SOURCE_MODE, + CONF_SOURCES, + SOURCE_MODE_NOT_USED, + SUBENTRY_TYPE_BATTERY, +) +from ..source_pipeline import build_source_value_provider +from ..source_types import SourceProvider, SourceProviderError, SourceWindow +from .models import ( + FLAG_GAP, + FLAG_METER_RESET, + FLAG_MISSING_EXPORT_PRICE, + FLAG_MISSING_IMPORT_PRICE, + FLAG_MISSING_METER, + FLAG_SELF_CONSUMPTION_UNAVAILABLE, + HistoricalMetric, + SlotRecord, +) +from .simulations import ( + BatterySimulationConfig, + simulate_self_consumption_slot, +) +from .store import HistoricalCostStore, HistoricalPeriodSummary + +_LOGGER = logging.getLogger(__name__) +SCHEDULE_OFFSET = timedelta(seconds=5) + +type HistoricalUpdateListener = Callable[[], None] + + +class HistoricalCostTracker: + """Track completed historical cost slots for one config entry.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + *, + slot_minutes: int, + ) -> None: + """Initialize the tracker.""" + self.hass = hass + self.entry = entry + self.slot_minutes = int(slot_minutes) + self.store = HistoricalCostStore( + hass, + entry_id=entry.entry_id, + slot_minutes=slot_minutes, + currency=hass.config.currency, + ) + self._interval = timedelta(minutes=self.slot_minutes) + self._unsub_timer: CALLBACK_TYPE | None = None + self._source_providers: dict[str, SourceProvider] = {} + self._listeners: set[HistoricalUpdateListener] = set() + + async def async_start(self) -> None: + """Load state, seed cursors, and start scheduling.""" + await self.store.async_load() + if self._meter_config() != self.store.data.get("meter_config"): + await self._async_seed(datetime.now(tz=UTC)) + return + if not self.store.last_meter_values(): + await self._async_seed(datetime.now(tz=UTC)) + self._schedule_next(datetime.now(tz=UTC)) + + async def async_shutdown(self) -> None: + """Stop scheduling and flush pending store state.""" + if self._unsub_timer is not None: + self._unsub_timer() + self._unsub_timer = None + await self.store.async_flush() + + @callback + def async_add_listener(self, listener: HistoricalUpdateListener) -> CALLBACK_TYPE: + """Subscribe to historical data updates.""" + self._listeners.add(listener) + + @callback + def _remove() -> None: + self._listeners.discard(listener) + + return _remove + + def summary( + self, + *, + metric: HistoricalMetric, + period: str, + scenario: str | None, + ) -> HistoricalPeriodSummary: + """Return an aggregate summary for an entity.""" + return self.store.summary(metric=metric, period=period, scenario=scenario) + + def scenario_enabled(self, scenario: str | None) -> bool: + """Return whether a historical scenario is currently enabled.""" + if scenario is None or scenario == "actual": + return True + if scenario == "no_battery": + return bool(self.entry.options.get(CONF_HISTORICAL_SIMULATE_NO_BATTERY, True)) + if scenario == "self_consumption": + return bool( + self.entry.options.get( + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, + True, + ) + ) + return False + + def remember_price_series( + self, + *, + start_at: datetime, + slot_minutes: int, + import_prices: list[float], + export_prices: list[float], + ) -> None: + """Retain normalized planner prices for later historical slot processing.""" + self.store.remember_price_series( + start_at=start_at, + slot_minutes=slot_minutes, + import_prices=import_prices, + export_prices=export_prices, + ) + self._notify() + + async def async_process_completed_slot( + self, + now: datetime | None = None, + ) -> None: + """Process the latest completed slot if one is ready.""" + now = now or datetime.now(tz=UTC) + completed_slot = self._floor_to_slot(now) - self._interval + last_processed = self.store.last_processed_slot() + if last_processed is None or not self.store.last_meter_values(): + await self._async_seed(now) + return + if completed_slot <= last_processed: + return + if completed_slot != last_processed + self._interval: + missing_slot = last_processed + self._interval + while missing_slot <= completed_slot: + await self._async_append_gap(missing_slot, FLAG_GAP) + missing_slot += self._interval + await self._async_seed(now, processed_slot=completed_slot) + self._notify() + return + + current_meters, meter_flags = self._read_meter_values() + previous_meters = self.store.last_meter_values() + deltas, delta_flags = self._meter_deltas(previous_meters, current_meters) + flags = meter_flags | delta_flags + import_price = await self._async_price(CONF_SOURCE_IMPORT_PRICE, completed_slot) + if import_price is None: + flags |= FLAG_MISSING_IMPORT_PRICE + export_price = await self._async_export_price(completed_slot) + if export_price is None: + flags |= FLAG_MISSING_EXPORT_PRICE + + self_import: float | None = None + self_export: float | None = None + if self.scenario_enabled("self_consumption"): + simulation = self._simulate_self_consumption(deltas) + if simulation is None: + flags |= FLAG_SELF_CONSUMPTION_UNAVAILABLE + else: + self_import, self_export = simulation + + record = SlotRecord( + start=completed_slot, + import_price=import_price, + export_price=export_price, + grid_import=deltas.get("grid_import"), + grid_export=deltas.get("grid_export"), + usage=deltas.get("usage"), + pv=deltas.get("pv"), + self_consumption_grid_import=self_import, + self_consumption_grid_export=self_export, + flags=flags, + ) + self.store.append_slot(record) + self.store.update_metadata( + last_processed_slot=completed_slot, + last_meter_values=current_meters, + meter_config=self._meter_config(), + ) + self.store.data.pop("meter_cursor_seeded", None) + self._notify() + + async def async_refresh(self, now: datetime | None = None) -> None: + """Process due historical data and publish current aggregate state.""" + await self.async_process_completed_slot(now) + self._notify() + + async def _async_timer(self, now: datetime) -> None: + """Handle one scheduled tracker tick.""" + try: + await self.async_refresh(now) + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "Historical cost tracking failed (entry_id=%s): %s", + self.entry.entry_id, + err, + ) + finally: + self._schedule_next(datetime.now(tz=UTC)) + + def _schedule_next(self, now: datetime) -> None: + """Schedule the next slot-aligned tick.""" + if self._unsub_timer is not None: + self._unsub_timer() + refresh_at = self._floor_to_slot(now) + self._interval + SCHEDULE_OFFSET + self._unsub_timer = async_track_point_in_utc_time( + self.hass, + self._async_timer, + refresh_at, + ) + + async def _async_seed( + self, + now: datetime, + *, + processed_slot: datetime | None = None, + ) -> None: + """Seed meter cursors and self-consumption SoC without creating a slot.""" + meters, _flags = self._read_meter_values() + seed_slot = processed_slot or self._floor_to_slot(now) + if self.scenario_enabled("self_consumption"): + self._seed_self_consumption_soc() + self.store.update_metadata( + last_processed_slot=seed_slot, + last_meter_values=meters, + meter_config=self._meter_config(), + ) + if processed_slot is None: + self.store.data["meter_cursor_seeded"] = True + self._schedule_next(now) + + async def _async_append_gap(self, slot_start: datetime, flags: int) -> None: + """Record an explicit missing/gap slot.""" + record = SlotRecord( + start=slot_start, + import_price=None, + export_price=None, + grid_import=None, + grid_export=None, + usage=None, + pv=None, + flags=flags, + ) + self.store.append_slot(record) + + def _read_meter_values(self) -> tuple[dict[str, float | None], int]: + """Read configured cumulative meter states.""" + config = self._meter_config() + values: dict[str, float | None] = { + "grid_import": None, + "grid_export": 0.0, + "usage": None, + "pv": 0.0, + } + flags = 0 + for key, required in ( + ("grid_import", True), + ("usage", True), + ("grid_export", False), + ("pv", False), + ): + entity_id = config.get(key) + if not entity_id: + if required: + flags |= FLAG_MISSING_METER + continue + value = self._float_state(str(entity_id)) + if value is None: + flags |= FLAG_MISSING_METER + values[key] = value + return values, flags + + def _meter_deltas( + self, + previous: dict[str, float | None], + current: dict[str, float | None], + ) -> tuple[dict[str, float | None], int]: + """Return cumulative meter deltas and validation flags.""" + deltas: dict[str, float | None] = {} + flags = 0 + for key in ("grid_import", "grid_export", "usage", "pv"): + previous_value = previous.get(key) + current_value = current.get(key) + if previous_value is None or current_value is None: + deltas[key] = None + flags |= FLAG_MISSING_METER + continue + delta = float(current_value) - float(previous_value) + if delta < 0.0: + deltas[key] = None + flags |= FLAG_METER_RESET + continue + deltas[key] = delta + return deltas, flags + + async def _async_price(self, source_key: str, slot_start: datetime) -> float | None: + """Fetch one configured price value for a slot.""" + cache_kind = { + CONF_SOURCE_IMPORT_PRICE: "import", + CONF_SOURCE_EXPORT_PRICE: "export", + }.get(source_key) + if cache_kind is not None: + cached = self.store.cached_price(slot_start, cache_kind) + if cached is not None: + return cached + + sources = self.entry.data.get(CONF_SOURCES, {}) + source_config = sources.get(source_key, {}) if isinstance(sources, dict) else {} + if not isinstance(source_config, dict): + return None + if source_config.get(CONF_SOURCE_MODE) in {None, SOURCE_MODE_NOT_USED}: + return None + try: + values = await self._source_provider(source_key, source_config).async_values( + SourceWindow( + start_at=slot_start, + slot_minutes=self.slot_minutes, + slots=1, + ) + ) + except SourceProviderError: + return None + if not values: + return None + try: + return float(values[0]) + except (TypeError, ValueError): + return None + + async def _async_export_price(self, slot_start: datetime) -> float | None: + """Return export price, defaulting disabled export value to zero.""" + if not self._meter_config().get("grid_export"): + return 0.0 + sources = self.entry.data.get(CONF_SOURCES, {}) + source_config = ( + sources.get(CONF_SOURCE_EXPORT_PRICE, {}) if isinstance(sources, dict) else {} + ) + if not isinstance(source_config, dict): + return 0.0 + if source_config.get(CONF_SOURCE_MODE) in {None, SOURCE_MODE_NOT_USED}: + return 0.0 + return await self._async_price(CONF_SOURCE_EXPORT_PRICE, slot_start) + + def _source_provider( + self, + source_key: str, + source_config: dict[str, Any], + ) -> SourceProvider: + """Return a cached source provider for price lookups.""" + provider_key = f"{source_key}:{source_config!r}" + if provider := self._source_providers.get(provider_key): + return provider + provider = build_source_value_provider( + self.hass, + source_key=source_key, + source_config=source_config, + ) + self._source_providers[provider_key] = provider + return provider + + def _simulate_self_consumption( + self, + deltas: dict[str, float | None], + ) -> tuple[float, float] | None: + """Run and persist one self-consumption simulation slot.""" + usage = deltas.get("usage") + pv = deltas.get("pv") + if usage is None or pv is None: + return None + batteries = self._battery_configs() + soc = self.store.simulation_soc() + if any(battery.subentry_id not in soc for battery in batteries): + self._seed_self_consumption_soc() + soc = self.store.simulation_soc() + if any(battery.subentry_id not in soc for battery in batteries): + return None + result = simulate_self_consumption_slot( + usage=float(usage), + pv=float(pv), + batteries=batteries, + soc_by_battery=soc, + ) + self.store.update_simulation_soc(result.soc_by_battery) + return result.grid_import, result.grid_export + + def _seed_self_consumption_soc(self) -> None: + """Seed self-consumption simulation SoC from configured battery sources.""" + soc: dict[str, float] = {} + for battery in self._battery_configs(): + subentry = self.entry.subentries.get(battery.subentry_id) + if subentry is None: + continue + raw = self._float_state(str(subentry.data.get(CONF_SOC_SOURCE))) + if raw is None: + continue + state = self.hass.states.get(str(subentry.data.get(CONF_SOC_SOURCE))) + if state is not None and state.attributes.get("unit_of_measurement") == "%": + raw = (raw / 100.0) * battery.capacity_kwh + soc[battery.subentry_id] = max( + battery.minimum_kwh, + min(battery.capacity_kwh, raw), + ) + self.store.update_simulation_soc(soc) + + def _battery_configs(self) -> list[BatterySimulationConfig]: + """Return configured batteries in config-entry order.""" + batteries: list[BatterySimulationConfig] = [] + for subentry in self.entry.subentries.values(): + if subentry.subentry_type != SUBENTRY_TYPE_BATTERY: + continue + batteries.append( + BatterySimulationConfig( + subentry_id=subentry.subentry_id, + minimum_kwh=float(subentry.data[CONF_MINIMUM_KWH]), + capacity_kwh=float(subentry.data[CONF_CAPACITY_KWH]), + max_charge_kwh=self._kw_to_slot_kwh( + float(subentry.data[CONF_MAX_CHARGE_KW]) + ), + max_discharge_kwh=self._kw_to_slot_kwh( + float(subentry.data[CONF_MAX_DISCHARGE_KW]) + ), + charge_efficiency=float( + subentry.data.get(CONF_CHARGE_EFFICIENCY, 0.9) + ), + discharge_efficiency=float( + subentry.data.get(CONF_DISCHARGE_EFFICIENCY, 0.9) + ), + can_charge_from_pv=bool( + subentry.data.get(CONF_CAN_CHARGE_FROM_PV, True) + ), + ) + ) + return batteries + + def _meter_config(self) -> dict[str, str | None]: + """Return normalized meter config from entry options.""" + return { + "grid_import": self._option_entity(CONF_HISTORICAL_GRID_IMPORT_SENSOR), + "grid_export": self._option_entity(CONF_HISTORICAL_GRID_EXPORT_SENSOR), + "usage": self._option_entity(CONF_HISTORICAL_USAGE_SENSOR), + "pv": self._option_entity(CONF_HISTORICAL_PV_SENSOR), + } + + def _option_entity(self, key: str) -> str | None: + value = self.entry.options.get(key) + if not value: + return None + return str(value) + + def _float_state(self, entity_id: str) -> float | None: + """Return a numeric state value.""" + if not entity_id: + return None + state = self.hass.states.get(entity_id) + if state is None or state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}: + return None + try: + return float(state.state) + except (TypeError, ValueError): + return None + + def _kw_to_slot_kwh(self, power_kw: float) -> float: + return power_kw * (self.slot_minutes / 60.0) + + def _floor_to_slot(self, value: datetime) -> datetime: + seconds = int(value.astimezone(UTC).timestamp()) + slot_seconds = self.slot_minutes * 60 + floored = (seconds // slot_seconds) * slot_seconds + return datetime.fromtimestamp(floored, tz=UTC) + + def _notify(self) -> None: + """Notify historical sensors.""" + for listener in list(self._listeners): + listener() + + +def validate_energy_sensor(hass: HomeAssistant, entity_id: str | None) -> bool: + """Return whether an entity looks like a cumulative kWh energy sensor.""" + if not entity_id: + return False + if not str(entity_id).startswith("sensor."): + return False + state = hass.states.get(str(entity_id)) + if state is None: + return True + device_class = state.attributes.get("device_class") + if device_class not in {None, SensorDeviceClass.ENERGY, "energy"}: + return False + unit = state.attributes.get("unit_of_measurement") + return unit in {None, UnitOfEnergy.KILO_WATT_HOUR, "kWh"} diff --git a/custom_components/wattplan/runtime.py b/custom_components/wattplan/runtime.py index 15aea07..301d0c3 100644 --- a/custom_components/wattplan/runtime.py +++ b/custom_components/wattplan/runtime.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigEntry from .coordinator import WattPlanCoordinator +from .historical_cost.tracker import HistoricalCostTracker @dataclass @@ -25,6 +26,7 @@ class WattPlanRuntimeData: coordinator: WattPlanCoordinator last_run_at: datetime + historical_tracker: HistoricalCostTracker | None = None optimizer_state: str | None = None runtime_update_listeners: set[Callable[[], None]] = field(default_factory=set) battery_targets: dict[str, BatteryTarget] = field(default_factory=dict) diff --git a/custom_components/wattplan/sensor.py b/custom_components/wattplan/sensor.py index 90dbbb7..2268dd0 100644 --- a/custom_components/wattplan/sensor.py +++ b/custom_components/wattplan/sensor.py @@ -38,14 +38,12 @@ NextRunSensor, OptionalTimestampSensor, PlanDetailsSensor, - ProjectionSensor, - ProjectionValueTransform, SourceStatusSensor, StatusMessageSensor, StatusSensor, UsageForecastSensor, ) -from .sensors.common import MAX_EXPOSED_PROJECTED_SAVINGS_PCT +from .sensors.historical import build_historical_sensors from .sensor_specs import ENTRY_SENSOR_SPECS, OPTIONAL_SOURCE_STATUS_SPECS ENTRY_FRIENDLY_NAMES: dict[str, str] = { @@ -90,20 +88,6 @@ def _entry_sensor_name( sensor_key: str, *, slot_minutes: int, hours_to_plan: int ) -> str: """Return explicit entry-level sensor name.""" - if sensor_key == "projected_cost_savings": - return f"Projected Cost Savings over {_duration_label(minutes=hours_to_plan * 60)}" - if sensor_key == "projected_savings_percentage": - return ( - "Projected Savings Percentage over " - f"{_duration_label(minutes=hours_to_plan * 60)}" - ) - if sensor_key == "projected_cost_savings_this_interval": - return f"Projected Cost Savings over {_duration_label(minutes=slot_minutes)}" - if sensor_key == "projected_savings_percentage_this_interval": - return ( - "Projected Savings Percentage over " - f"{_duration_label(minutes=slot_minutes)}" - ) return ENTRY_FRIENDLY_NAMES[sensor_key] @@ -121,15 +105,6 @@ def _subentry_sensor_name(subentry_name: str, sensor_key: str) -> str: option_number = sensor_key[len("option_") : -len("_start")] return f"({subentry_name}) Option {option_number} Start" raise ValueError(f"Unsupported subentry sensor key: {sensor_key}") -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 - - def _entry_sensor_kwargs( config_entry: ConfigEntry, *, @@ -204,19 +179,10 @@ def entry_kwargs(sensor_key: str) -> dict[str, Any]: "last_run_duration": LastRunDurationSensor, "plan_details": PlanDetailsSensor, "plan_details_hourly": PlanDetailsSensor, - "projected_cost_savings": ProjectionSensor, - "projected_savings_percentage": ProjectionSensor, - "projected_cost_savings_this_interval": ProjectionSensor, - "projected_savings_percentage_this_interval": ProjectionSensor, } sensors: list[SensorEntity] = [] for spec in ENTRY_SENSOR_SPECS: extra_kwargs = dict(spec.extra_kwargs) - if spec.sensor_key in { - "projected_savings_percentage", - "projected_savings_percentage_this_interval", - }: - extra_kwargs["value_transform"] = _projected_savings_percentage_value_transform sensor_class = spec_classes[spec.sensor_key] sensors.append( sensor_class(config_entry, coordinator, **extra_kwargs, **entry_kwargs(spec.sensor_key)) @@ -401,4 +367,13 @@ async def async_setup_entry( ) ) + if runtime_data.historical_tracker is not None: + sensors.extend( + build_historical_sensors( + config_entry, + runtime_data.historical_tracker, + entry_slug=entry_slug, + ) + ) + async_add_entities(sensors) diff --git a/custom_components/wattplan/sensor_specs.py b/custom_components/wattplan/sensor_specs.py index 663e8c4..b7cf3e0 100644 --- a/custom_components/wattplan/sensor_specs.py +++ b/custom_components/wattplan/sensor_specs.py @@ -31,42 +31,6 @@ class SensorSpec: SensorSpec("last_run_duration", object), SensorSpec("plan_details", object, {"details_key": "plan_details"}), SensorSpec("plan_details_hourly", object, {"details_key": "plan_details_hourly"}), - SensorSpec( - "projected_cost_savings", - object, - { - "projection_key": "projected_savings_cost", - "aggregate_mode": "horizon", - "use_home_currency": True, - }, - ), - SensorSpec( - "projected_savings_percentage", - object, - { - "projection_key": "projected_savings_pct", - "aggregate_mode": "horizon", - "native_unit_of_measurement": "%", - }, - ), - SensorSpec( - "projected_cost_savings_this_interval", - object, - { - "projection_key": "projected_savings_cost", - "aggregate_mode": "next_interval", - "use_home_currency": True, - }, - ), - SensorSpec( - "projected_savings_percentage_this_interval", - object, - { - "projection_key": "projected_savings_pct", - "aggregate_mode": "next_interval", - "native_unit_of_measurement": "%", - }, - ), ) OPTIONAL_SOURCE_STATUS_SPECS: tuple[tuple[str, str], ...] = ( diff --git a/custom_components/wattplan/sensors/historical.py b/custom_components/wattplan/sensors/historical.py new file mode 100644 index 0000000..f785efa --- /dev/null +++ b/custom_components/wattplan/sensors/historical.py @@ -0,0 +1,206 @@ +"""Historical cost and savings sensors.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE + +from ..const import DOMAIN +from ..historical_cost.models import ( + HistoricalMetric, + HistoricalSensorDescription, + PERIOD_THIS_MONTH, + PERIOD_TODAY, + SCENARIO_ACTUAL, + SCENARIO_NO_BATTERY, + SCENARIO_SELF_CONSUMPTION, +) +from ..historical_cost.tracker import HistoricalCostTracker +from .common import entry_device_info + +HISTORICAL_SENSOR_DESCRIPTIONS: tuple[HistoricalSensorDescription, ...] = ( + HistoricalSensorDescription( + key="historical_actual_cost_today", + metric=HistoricalMetric.COST, + period=PERIOD_TODAY, + scenario=SCENARIO_ACTUAL, + name="Actual Cost Today", + enabled_default=True, + ), + HistoricalSensorDescription( + key="historical_no_battery_cost_today", + metric=HistoricalMetric.COST, + period=PERIOD_TODAY, + scenario=SCENARIO_NO_BATTERY, + name="No Battery Cost Today", + enabled_default=True, + ), + HistoricalSensorDescription( + key="historical_self_consumption_cost_today", + metric=HistoricalMetric.COST, + period=PERIOD_TODAY, + scenario=SCENARIO_SELF_CONSUMPTION, + name="Self Consumption Cost Today", + enabled_default=True, + ), + HistoricalSensorDescription( + key="historical_savings_vs_no_battery_today", + metric=HistoricalMetric.SAVINGS_VS_NO_BATTERY, + period=PERIOD_TODAY, + scenario=None, + name="Savings Today vs No Battery", + enabled_default=True, + ), + HistoricalSensorDescription( + key="historical_savings_vs_self_consumption_today", + metric=HistoricalMetric.SAVINGS_VS_SELF_CONSUMPTION, + period=PERIOD_TODAY, + scenario=None, + name="Savings Today vs Self Consumption", + enabled_default=True, + ), + HistoricalSensorDescription( + key="historical_actual_cost_this_month", + metric=HistoricalMetric.COST, + period=PERIOD_THIS_MONTH, + scenario=SCENARIO_ACTUAL, + name="Actual Cost This Month", + enabled_default=False, + ), + HistoricalSensorDescription( + key="historical_no_battery_cost_this_month", + metric=HistoricalMetric.COST, + period=PERIOD_THIS_MONTH, + scenario=SCENARIO_NO_BATTERY, + name="No Battery Cost This Month", + enabled_default=False, + ), + HistoricalSensorDescription( + key="historical_self_consumption_cost_this_month", + metric=HistoricalMetric.COST, + period=PERIOD_THIS_MONTH, + scenario=SCENARIO_SELF_CONSUMPTION, + name="Self Consumption Cost This Month", + enabled_default=False, + ), + HistoricalSensorDescription( + key="historical_savings_vs_no_battery_this_month", + metric=HistoricalMetric.SAVINGS_VS_NO_BATTERY, + period=PERIOD_THIS_MONTH, + scenario=None, + name="Savings This Month vs No Battery", + enabled_default=False, + ), + HistoricalSensorDescription( + key="historical_savings_vs_self_consumption_this_month", + metric=HistoricalMetric.SAVINGS_VS_SELF_CONSUMPTION, + period=PERIOD_THIS_MONTH, + scenario=None, + name="Savings This Month vs Self Consumption", + enabled_default=False, + ), +) + + +class HistoricalCostSensor(SensorEntity): + """Historical cost or savings aggregate sensor.""" + + _attr_should_poll = False + _attr_device_class = SensorDeviceClass.MONETARY + _attr_suggested_display_precision = 2 + + def __init__( + self, + config_entry: ConfigEntry, + tracker: HistoricalCostTracker, + description: HistoricalSensorDescription, + *, + entry_slug: str, + ) -> None: + """Initialize the sensor.""" + self._tracker = tracker + self._description = description + self._attr_name = description.name + self._attr_object_id = f"{entry_slug}_{description.key}" + self.internal_integration_suggested_object_id = self._attr_object_id + self._attr_unique_id = f"{config_entry.entry_id}:historical:{description.key}" + self._attr_native_unit_of_measurement = tracker.hass.config.currency + self._attr_entity_registry_enabled_default = description.enabled_default + self._attr_device_info = entry_device_info(config_entry) + self._remove_listener: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Subscribe to tracker updates.""" + self._remove_listener = self._tracker.async_add_listener( + self.async_write_ha_state + ) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from tracker updates.""" + if self._remove_listener is not None: + self._remove_listener() + self._remove_listener = None + + @property + def available(self) -> bool: + """Return if this aggregate currently has a value.""" + if not self._scenario_enabled(): + return False + return self._summary().value is not None + + @property + def native_value(self) -> float | None: + """Return the aggregate value.""" + if not self._scenario_enabled(): + return None + return self._summary().value + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return period and retention metadata.""" + summary = self._summary() + return { + "tracking_started_at": summary.tracking_started_at, + "last_complete_slot": summary.last_complete_slot, + "slots": summary.slots, + "missing_slots": summary.missing_slots, + "period_start": summary.period_start, + "period_end": summary.period_end, + "scenario": summary.scenario, + } + + def _summary(self): + return self._tracker.summary( + metric=self._description.metric, + period=self._description.period, + scenario=self._description.scenario, + ) + + def _scenario_enabled(self) -> bool: + description = self._description + if description.metric is HistoricalMetric.SAVINGS_VS_NO_BATTERY: + return self._tracker.scenario_enabled(SCENARIO_NO_BATTERY) + if description.metric is HistoricalMetric.SAVINGS_VS_SELF_CONSUMPTION: + return self._tracker.scenario_enabled(SCENARIO_SELF_CONSUMPTION) + return self._tracker.scenario_enabled(description.scenario) + + +def build_historical_sensors( + config_entry: ConfigEntry, + tracker: HistoricalCostTracker, + *, + entry_slug: str, +) -> list[HistoricalCostSensor]: + """Build all historical cost sensors for one config entry.""" + return [ + HistoricalCostSensor( + config_entry, + tracker, + description, + entry_slug=entry_slug, + ) + for description in HISTORICAL_SENSOR_DESCRIPTIONS + ] diff --git a/custom_components/wattplan/services.py b/custom_components/wattplan/services.py index a1fa798..4b1e848 100644 --- a/custom_components/wattplan/services.py +++ b/custom_components/wattplan/services.py @@ -276,11 +276,15 @@ async def async_handle_run_optimize_now_service( ) -> None: for entry in resolve_run_entries(hass, call): await entry.runtime_data.coordinator.async_plan(trigger=CycleTrigger.SERVICE) + if entry.runtime_data.historical_tracker is not None: + await entry.runtime_data.historical_tracker.async_refresh() mark_runtime_updated(entry.runtime_data, when=datetime.now(tz=UTC)) async def async_handle_refresh_sensors_service(hass: HomeAssistant, call: ServiceCall) -> None: for entry in resolve_run_entries(hass, call): + if entry.runtime_data.historical_tracker is not None: + await entry.runtime_data.historical_tracker.async_refresh() await entry.runtime_data.coordinator.async_emit(trigger=CycleTrigger.SERVICE) diff --git a/custom_components/wattplan/strings.json b/custom_components/wattplan/strings.json index 1b8b689..0bd6824 100644 --- a/custom_components/wattplan/strings.json +++ b/custom_components/wattplan/strings.json @@ -715,6 +715,7 @@ "source_export_price": "Export price source", "source_usage": "Usage source", "source_pv": "Solar source", + "historical_costs": "Historical costs", "battery_entities": "Battery entities", "comfort_entities": "Comfort entities", "optional_entities": "Optional entities", @@ -1123,6 +1124,36 @@ "action_emission_enabled": "When enabled, battery policy states like preserve/self_consume/grid_charge are published every {slot_minutes} minutes." } }, + "historical_costs": { + "title": "Historical costs", + "description": "Track measured cost and compare it with simple reference scenarios from completed slots. Use this to see whether WattPlan is actually improving your setup over time.\n\nRead the historical cost guide before enabling this: [Open historical cost guide](https://github.com/LordMike/WattPlan/blob/main/docs/historical-cost-tracking.md).\n\nCurrent status: **{current_status}**", + "data": { + "historical_cost_tracking_enabled": "Enable historical cost tracking" + }, + "data_description": { + "historical_cost_tracking_enabled": "When enabled, WattPlan samples completed slots from cumulative energy sensors and exposes daily and monthly historical cost entities." + } + }, + "historical_costs_settings": { + "title": "Historical cost settings", + "description": "Select the cumulative energy sensors WattPlan should sample for completed slots, then choose which reference scenarios to calculate.\n\nNeed help choosing sensors or reading the resulting numbers? [Open historical cost guide](https://github.com/LordMike/WattPlan/blob/main/docs/historical-cost-tracking.md).", + "data": { + "historical_grid_import_sensor": "Grid import cumulative kWh sensor", + "historical_grid_export_sensor": "Grid export cumulative kWh sensor", + "historical_usage_sensor": "Usage cumulative kWh sensor", + "historical_pv_sensor": "PV cumulative kWh sensor", + "historical_simulate_no_battery": "Enable no-battery simulation", + "historical_simulate_self_consumption": "Enable self-consumption simulation" + }, + "data_description": { + "historical_grid_import_sensor": "Required when tracking is enabled. Select the total-increasing kWh sensor that measures energy imported from the grid.", + "historical_grid_export_sensor": "Optional. Select the total-increasing kWh sensor that measures energy exported to the grid. Leave empty when exported power should be treated as zero.", + "historical_usage_sensor": "Required when tracking is enabled. Select the total-increasing kWh sensor that measures total site usage or consumption.", + "historical_pv_sensor": "Optional. Select the total-increasing kWh sensor that measures PV production. Leave empty when PV is not part of this setup.", + "historical_simulate_no_battery": "When enabled, WattPlan also calculates a reference cost where usage is served by PV first and any remaining deficit or surplus goes directly to the grid.\n\nThis scenario can be used to determine if WattPlan is helpful by comparing measured cost against a setup with no batteries at all.", + "historical_simulate_self_consumption": "When enabled, WattPlan also calculates a reference cost where PV serves usage first, PV surplus charges batteries, and batteries discharge before grid import.\n\nThis scenario can be used to determine if WattPlan is helpful by comparing measured cost against a common rule-based battery strategy with no scheduling benefit." + } + }, "planner_timers_warning_planning": { "title": "Manual planning acknowledgement", "description": "Scheduled planning is disabled.\n\nWattPlan will not create fresh plans automatically. Fresh plan generation is now user-managed, and you must call `wattplan.run_optimize_now` from your own automation or schedule when you want WattPlan to read sources and calculate a new plan.", diff --git a/custom_components/wattplan/translations/en.json b/custom_components/wattplan/translations/en.json index 42133a7..0696f00 100644 --- a/custom_components/wattplan/translations/en.json +++ b/custom_components/wattplan/translations/en.json @@ -715,6 +715,7 @@ "source_export_price": "Export price source", "source_usage": "Usage source", "source_pv": "Solar source", + "historical_costs": "Historical costs", "battery_entities": "Battery entities", "comfort_entities": "Comfort entities", "optional_entities": "Optional entities", @@ -1123,6 +1124,36 @@ "action_emission_enabled": "When enabled, battery policy states like preserve/self_consume/grid_charge are published every {slot_minutes} minutes." } }, + "historical_costs": { + "title": "Historical costs", + "description": "Track measured cost and compare it with simple reference scenarios from completed slots. Use this to see whether WattPlan is actually improving your setup over time.\n\nRead the historical cost guide before enabling this: [Open historical cost guide](https://github.com/LordMike/WattPlan/blob/main/docs/historical-cost-tracking.md).\n\nCurrent status: **{current_status}**", + "data": { + "historical_cost_tracking_enabled": "Enable historical cost tracking" + }, + "data_description": { + "historical_cost_tracking_enabled": "When enabled, WattPlan samples completed slots from cumulative energy sensors and exposes daily and monthly historical cost entities." + } + }, + "historical_costs_settings": { + "title": "Historical cost settings", + "description": "Select the cumulative energy sensors WattPlan should sample for completed slots, then choose which reference scenarios to calculate.\n\nNeed help choosing sensors or reading the resulting numbers? [Open historical cost guide](https://github.com/LordMike/WattPlan/blob/main/docs/historical-cost-tracking.md).", + "data": { + "historical_grid_import_sensor": "Grid import cumulative kWh sensor", + "historical_grid_export_sensor": "Grid export cumulative kWh sensor", + "historical_usage_sensor": "Usage cumulative kWh sensor", + "historical_pv_sensor": "PV cumulative kWh sensor", + "historical_simulate_no_battery": "Enable no-battery simulation", + "historical_simulate_self_consumption": "Enable self-consumption simulation" + }, + "data_description": { + "historical_grid_import_sensor": "Required when tracking is enabled. Select the total-increasing kWh sensor that measures energy imported from the grid.", + "historical_grid_export_sensor": "Optional. Select the total-increasing kWh sensor that measures energy exported to the grid. Leave empty when exported power should be treated as zero.", + "historical_usage_sensor": "Required when tracking is enabled. Select the total-increasing kWh sensor that measures total site usage or consumption.", + "historical_pv_sensor": "Optional. Select the total-increasing kWh sensor that measures PV production. Leave empty when PV is not part of this setup.", + "historical_simulate_no_battery": "When enabled, WattPlan also calculates a reference cost where usage is served by PV first and any remaining deficit or surplus goes directly to the grid.\n\nThis scenario can be used to determine if WattPlan is helpful by comparing measured cost against a setup with no batteries at all.", + "historical_simulate_self_consumption": "When enabled, WattPlan also calculates a reference cost where PV serves usage first, PV surplus charges batteries, and batteries discharge before grid import.\n\nThis scenario can be used to determine if WattPlan is helpful by comparing measured cost against a common rule-based battery strategy with no scheduling benefit." + } + }, "planner_timers_warning_planning": { "title": "Manual planning acknowledgement", "description": "Scheduled planning is disabled.\n\nWattPlan will not create fresh plans automatically. Fresh plan generation is now user-managed, and you must call `wattplan.run_optimize_now` from your own automation or schedule when you want WattPlan to read sources and calculate a new plan.", diff --git a/docs/entities-and-services.md b/docs/entities-and-services.md index a085f9d..3d0b7b8 100644 --- a/docs/entities-and-services.md +++ b/docs/entities-and-services.md @@ -24,15 +24,11 @@ These exist once per WattPlan setup: | `sensor._last_run` | Timestamp of the last successful optimize (plan calculation) cycle. | | `sensor._next_run` | Disabled by default. Timestamp of the next scheduled planning cycle. | | `sensor._last_run_duration` | Disabled by default. Duration of the last optimize 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. 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_this_interval` | Disabled by default. Savings for the current planner interval only. | -| `sensor._projected_savings_percentage_this_interval` | Disabled by default. Savings percentage for the current 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. | -When `sensor._status` is `failed`, plan-dependent entities such as action sensors, plan details, projected savings, and usage forecast become unavailable rather than continuing to expose stale plan data. +When `sensor._status` is `failed`, plan-dependent entities such as action sensors, plan details, and usage forecast become unavailable rather than continuing to expose stale plan data. The overall status sensor is the canonical view of whether the current plan is usable. Its `plan_created_at` attribute is the snapshot creation time, and its `expires_at` attribute is the end of the current usable plan coverage from the optimizer horizon. If planning fails but WattPlan retains a previous snapshot, `expires_at` continues to describe that retained plan. Once the retained or active plan no longer covers the current time, the overall status becomes `failed`, `is_stale` becomes `true`, and `has_usable_plan` becomes `false`. diff --git a/docs/historical-cost-tracking.md b/docs/historical-cost-tracking.md new file mode 100644 index 0000000..da6a3c9 --- /dev/null +++ b/docs/historical-cost-tracking.md @@ -0,0 +1,90 @@ +# Historical Cost Tracking + +Historical cost tracking helps answer whether WattPlan is actually improving cost over time. It compares the measured cost of what happened in your home with simple reference scenarios calculated from the same completed energy slots. + +Enable it after the main WattPlan setup is working and your automations are applying WattPlan's actions. Historical tracking is disabled by default. + +## Setup Requirements + +Historical tracking needs cumulative `kWh` meter sensors. These are different from the here-and-now forecast or power sensors used for planning: + +| Sensor type | Used for | Example shape | +| --- | --- | --- | +| Planning sources | Future price, usage, and PV values for the optimizer. These can be forecast attributes, services, templates, or generated forecasts. | "What will the price/load/PV be for each future slot?" | +| Historical meters | Past measured energy totals. WattPlan reads the difference between two completed slots. | "The grid import meter has increased from 100.0 kWh to 101.2 kWh." | + +Required historical meters: + +| Meter | Required | Purpose | +| --- | --- | --- | +| Grid import | Yes | Measures how much energy was bought from the grid. | +| Usage/load | Yes | Measures total household consumption for the reference scenarios. | +| Grid export | No | Measures exported energy. If not configured, export is treated as zero. | +| PV production | No | Measures solar production for reference scenarios. If not configured, PV is treated as zero. | + +Use sensors with a steadily increasing `kWh` total, usually with Home Assistant device class `energy` and state class `total` or `total_increasing`. Do not use instant `kW` power sensors, current battery level sensors, or forecast-only sensors as historical meters. + +Historical tracking also needs prices for each completed slot. WattPlan keeps the normalized import/export prices from successful planner runs and falls back to live price source reads when needed. + +## How The Numbers Update + +Historical cost sensors are period-to-date totals, not last-slot snapshots. + +| Period | Meaning | +| --- | --- | +| `today` | Accumulated from local midnight through the latest completed slot. | +| `this_month` | Accumulated from the first day of the local month through the latest completed slot. | + +WattPlan only processes completed slots. If the setup uses 15-minute slots, the values update after a full 15-minute interval has finished. Missing meters, meter resets, missing prices, and skipped slots are counted as missing slots instead of being spread across multiple prices. + +## Scenarios + +| Scenario | What it means | How to read it | +| --- | --- | --- | +| Actual | What really happened after all planning, automation, manual control, or lack of control. | Grid import cost minus grid export value. Lower is better when comparing raw cost sensors. | +| No battery | A reference where the home has no usable battery storage. It uses the same measured usage and PV, but treats battery capacity as zero: PV serves usage first, remaining usage comes from the grid, and PV surplus is exported. | Useful for seeing whether the real setup is cheaper than the same home with no batteries installed or available. | +| Self-consumption | A reference where PV serves usage first, PV surplus charges configured batteries, and batteries discharge before grid import. It has no grid charging, no price awareness, and no preserve behavior. | Usually the first comparison for battery setups because it represents a simple PV-first battery strategy. | + +The reference scenarios are not predictions. They are recalculated from the same measured usage and PV facts that occurred in the completed slots. + +## Entities + +Enabled by default when historical tracking is enabled: + +| Entity | Meaning | +| --- | --- | +| `sensor._historical_actual_cost_today` | Actual measured net cost for today so far. | +| `sensor._historical_no_battery_cost_today` | No-battery reference cost for today so far. | +| `sensor._historical_self_consumption_cost_today` | Self-consumption reference cost for today so far. | +| `sensor._historical_savings_vs_no_battery_today` | No-battery reference cost minus actual cost for today so far. Positive means actual behavior is beating the no-battery model. | +| `sensor._historical_savings_vs_self_consumption_today` | Self-consumption reference cost minus actual cost for today so far. Positive means actual behavior is beating simple self-consumption. | + +Disabled by default: + +| Entity | Meaning | +| --- | --- | +| `sensor._historical_actual_cost_this_month` | Actual measured net cost for this month so far. | +| `sensor._historical_no_battery_cost_this_month` | No-battery reference cost for this month so far. | +| `sensor._historical_self_consumption_cost_this_month` | Self-consumption reference cost for this month so far. | +| `sensor._historical_savings_vs_no_battery_this_month` | No-battery reference cost minus actual cost for this month so far. Positive means actual behavior is beating the no-battery model. | +| `sensor._historical_savings_vs_self_consumption_this_month` | Self-consumption reference cost minus actual cost for this month so far. Positive means actual behavior is beating simple self-consumption. | + +## Reading Savings + +Savings sensors use this formula: + +```text +savings = reference cost - actual cost +``` + +That means: + +| Savings value | Meaning | +| --- | --- | +| Positive | Good for that comparison. Actual measured behavior cost less than the reference scenario. | +| Zero | Actual measured behavior cost the same as the reference scenario. | +| Negative | Actual measured behavior cost more than the reference scenario for the period so far. | + +For example, if `sensor._historical_savings_vs_no_battery_today` is `2.50`, the real setup is currently `2.50` cheaper than the no-battery model today. If it is `-2.50`, the real setup is currently `2.50` more expensive than the no-battery model today. + +Daily values can be noisy, especially early in the day when a battery may charge before later savings happen. Monthly sensors are usually better for judging whether WattPlan is helping over time. diff --git a/tests/integration/test_config_flow.py b/tests/integration/test_config_flow.py index 2584f34..8283231 100644 --- a/tests/integration/test_config_flow.py +++ b/tests/integration/test_config_flow.py @@ -4,7 +4,10 @@ from typing import Any from unittest.mock import AsyncMock +import voluptuous_serialize + from custom_components.wattplan.const import ( + CONF_CONFIG_ENTRY_ID, CONF_ACTION_EMISSION_ENABLED, CONF_AVAILABILITY_SOURCE, CONF_CAN_CHARGE_FROM_GRID, @@ -15,6 +18,13 @@ CONF_DURATION_MINUTES, CONF_ENERGY_KWH, CONF_EXPECTED_POWER_KW, + CONF_HISTORICAL_COST_TRACKING_ENABLED, + CONF_HISTORICAL_GRID_EXPORT_SENSOR, + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_PV_SENSOR, + CONF_HISTORICAL_SIMULATE_NO_BATTERY, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, + CONF_HISTORICAL_USAGE_SENSOR, CONF_HOURS_TO_PLAN, CONF_MAX_CHARGE_KW, CONF_MAX_CONSECUTIVE_OFF_MINUTES, @@ -31,22 +41,31 @@ CONF_SLOT_MINUTES, CONF_SOC_SOURCE, CONF_SOURCE_MODE, + CONF_SOURCE_PV, + CONF_SOURCE_USAGE, CONF_SOURCES, CONF_TARGET_ON_HOURS_PER_WINDOW, CONF_TEMPLATE, DOMAIN, + SOURCE_MODE_BUILT_IN, + SOURCE_MODE_ENTITY_ADAPTER, + SOURCE_MODE_ENERGY_PROVIDER, SOURCE_MODE_NOT_USED, SOURCE_MODE_TEMPLATE, SUBENTRY_TYPE_BATTERY, SUBENTRY_TYPE_COMFORT, SUBENTRY_TYPE_OPTIONAL, ) +from custom_components.wattplan.source_providers import CONF_WATTPLAN_ENTITY_ID import pytest from homeassistant import config_entries from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry as dr, entity_registry as er +from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("enable_custom_integrations") @@ -76,9 +95,68 @@ def _schema_default(result: dict[str, Any], field: str) -> Any: """Extract a default value from a flow form schema.""" schema = result["data_schema"].schema marker = next(key for key in schema if getattr(key, "schema", None) == field) + if not callable(marker.default): + return None return marker.default() +def _serialized_schema_field(result: dict[str, Any], field: str) -> dict[str, Any]: + """Return a serialized schema field from a flow form.""" + return next( + item + for item in voluptuous_serialize.convert( + result["data_schema"], custom_serializer=cv.custom_serializer + ) + if item.get("name") == field + ) + + +def _set_energy_sensor(hass: HomeAssistant, entity_id: str, value: str = "1.0") -> None: + """Set a cumulative kWh sensor state.""" + hass.states.async_set( + entity_id, + value, + { + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, + ) + + +def _register_sensor_on_device( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + *, + device_id: str, + entity_id: str, + device_class: str, + unit: str, +) -> None: + """Register a test sensor on a device and set its current state.""" + device = dr.async_get(hass).async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", device_id)}, + ) + entry = er.async_get(hass).async_get_or_create( + "sensor", + "test", + entity_id, + config_entry=config_entry, + device_id=device.id, + suggested_object_id=entity_id.removeprefix("sensor."), + original_device_class=device_class, + unit_of_measurement=unit, + ) + attributes = { + "device_class": device_class, + "unit_of_measurement": unit, + } + if device_class == "energy": + attributes["state_class"] = "total_increasing" + hass.states.async_set(entry.entity_id, "1.0", attributes) + + async def _finish_setup_if_needed( hass: HomeAssistant, result: dict[str, Any] ) -> dict[str, Any]: @@ -424,6 +502,7 @@ async def test_options_flow_add_core_and_one_of_each_asset( result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.MENU assert "source_export_price" in result["menu_options"] + assert "historical_costs" in result["menu_options"] result = await hass.config_entries.options.async_configure( result["flow_id"], {"next_step_id": "planner_timers"} @@ -465,9 +544,42 @@ async def test_options_flow_add_core_and_one_of_each_asset( ) assert result["type"] is FlowResultType.MENU + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "historical_costs"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "historical_costs" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_HISTORICAL_COST_TRACKING_ENABLED: True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "historical_costs_settings" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_HISTORICAL_GRID_IMPORT_SENSOR: "sensor.grid_import_total", + CONF_HISTORICAL_GRID_EXPORT_SENSOR: "sensor.grid_export_total", + CONF_HISTORICAL_USAGE_SENSOR: "sensor.usage_total", + CONF_HISTORICAL_PV_SENSOR: "sensor.pv_total", + CONF_HISTORICAL_SIMULATE_NO_BATTERY: True, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION: False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + updated = hass.config_entries.async_get_entry(entry.entry_id) assert updated is not None assert updated.options[CONF_ACTION_EMISSION_ENABLED] is False + assert updated.options[CONF_HISTORICAL_COST_TRACKING_ENABLED] is True + assert ( + updated.options[CONF_HISTORICAL_GRID_IMPORT_SENSOR] + == "sensor.grid_import_total" + ) + assert updated.options[CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION] is False assert CONF_SOURCES in updated.data result = await hass.config_entries.subentries.async_init( @@ -545,6 +657,201 @@ async def test_options_flow_add_core_and_one_of_each_asset( ) +async def test_historical_costs_disabled_intro_closes_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Historical cost intro should save disabled and close without details.""" + entry = await _create_basic_entry(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "historical_costs"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "historical_costs" + assert _schema_default(result, CONF_HISTORICAL_COST_TRACKING_ENABLED) is False + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_HISTORICAL_COST_TRACKING_ENABLED: False}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + updated = hass.config_entries.async_get_entry(entry.entry_id) + assert updated is not None + assert updated.options[CONF_HISTORICAL_COST_TRACKING_ENABLED] is False + + +async def test_historical_costs_prefills_discovered_source_meters( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """First-time historical enablement should suggest unambiguous meters.""" + entry = await _create_basic_entry(hass) + source_entry = MockConfigEntry(domain="test", entry_id="source-entry") + source_entry.add_to_hass(hass) + _set_energy_sensor(hass, "sensor.house_usage_total") + _register_sensor_on_device( + hass, + source_entry, + device_id="pv-inverter", + entity_id="sensor.pv_power", + device_class="power", + unit="W", + ) + _register_sensor_on_device( + hass, + source_entry, + device_id="pv-inverter", + entity_id="sensor.pv_energy_total", + device_class="energy", + unit="kWh", + ) + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_SOURCES: { + **entry.data[CONF_SOURCES], + CONF_SOURCE_USAGE: { + CONF_SOURCE_MODE: SOURCE_MODE_BUILT_IN, + CONF_WATTPLAN_ENTITY_ID: "sensor.house_usage_total", + }, + CONF_SOURCE_PV: { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_WATTPLAN_ENTITY_ID: "sensor.pv_power", + }, + }, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "historical_costs"} + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_HISTORICAL_COST_TRACKING_ENABLED: True}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "historical_costs_settings" + assert ( + _schema_default(result, CONF_HISTORICAL_USAGE_SENSOR) + == "sensor.house_usage_total" + ) + assert _schema_default(result, CONF_HISTORICAL_PV_SENSOR) == "sensor.pv_energy_total" + assert _schema_default(result, CONF_HISTORICAL_GRID_IMPORT_SENSOR) is None + assert _schema_default(result, CONF_HISTORICAL_GRID_EXPORT_SENSOR) is None + assert "default" not in _serialized_schema_field( + result, CONF_HISTORICAL_GRID_IMPORT_SENSOR + ) + assert "default" not in _serialized_schema_field( + result, CONF_HISTORICAL_GRID_EXPORT_SENSOR + ) + + +async def test_historical_costs_prefills_energy_provider_owned_meter( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Energy-provider sources should suggest one owned cumulative meter.""" + entry = await _create_basic_entry(hass) + solar_entry = MockConfigEntry(domain="forecast_solar", entry_id="solar-entry") + solar_entry.add_to_hass(hass) + _register_sensor_on_device( + hass, + solar_entry, + device_id="solar-system", + entity_id="sensor.solar_power", + device_class="power", + unit="W", + ) + _register_sensor_on_device( + hass, + solar_entry, + device_id="solar-system", + entity_id="sensor.solar_energy_total", + device_class="energy", + unit="kWh", + ) + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_SOURCES: { + **entry.data[CONF_SOURCES], + CONF_SOURCE_PV: { + CONF_SOURCE_MODE: SOURCE_MODE_ENERGY_PROVIDER, + CONF_CONFIG_ENTRY_ID: solar_entry.entry_id, + }, + }, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "historical_costs"} + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_HISTORICAL_COST_TRACKING_ENABLED: True}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "historical_costs_settings" + assert ( + _schema_default(result, CONF_HISTORICAL_PV_SENSOR) + == "sensor.solar_energy_total" + ) + + +async def test_historical_costs_does_not_prefill_existing_blank_option( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Already-enabled historical settings should keep intentionally blank fields.""" + entry = await _create_basic_entry(hass) + _set_energy_sensor(hass, "sensor.house_usage_total") + _set_energy_sensor(hass, "sensor.pv_energy_total") + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_SOURCES: { + **entry.data[CONF_SOURCES], + CONF_SOURCE_USAGE: { + CONF_SOURCE_MODE: SOURCE_MODE_BUILT_IN, + CONF_WATTPLAN_ENTITY_ID: "sensor.house_usage_total", + }, + CONF_SOURCE_PV: { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_WATTPLAN_ENTITY_ID: "sensor.pv_energy_total", + }, + }, + }, + options={ + **entry.options, + CONF_HISTORICAL_COST_TRACKING_ENABLED: True, + CONF_HISTORICAL_GRID_IMPORT_SENSOR: "sensor.grid_import_total", + CONF_HISTORICAL_USAGE_SENSOR: "sensor.house_usage_total", + CONF_HISTORICAL_PV_SENSOR: None, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "historical_costs"} + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_HISTORICAL_COST_TRACKING_ENABLED: True}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "historical_costs_settings" + assert _schema_default(result, CONF_HISTORICAL_PV_SENSOR) is None + + async def test_options_planner_timers_both_enabled_saves_without_warning( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: diff --git a/tests/integration/test_integration_runtime.py b/tests/integration/test_integration_runtime.py index caf2cae..b376313 100644 --- a/tests/integration/test_integration_runtime.py +++ b/tests/integration/test_integration_runtime.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, tzinfo +from datetime import UTC, datetime, timedelta, tzinfo from unittest.mock import patch from custom_components.wattplan.const import ( @@ -17,7 +17,15 @@ CONF_DURATION_MINUTES, CONF_ENERGY_KWH, CONF_EXPECTED_POWER_KW, + CONF_FIXUP_PROFILE, CONF_HOURS_TO_PLAN, + CONF_HISTORICAL_COST_TRACKING_ENABLED, + CONF_HISTORICAL_GRID_EXPORT_SENSOR, + CONF_HISTORICAL_GRID_IMPORT_SENSOR, + CONF_HISTORICAL_PV_SENSOR, + CONF_HISTORICAL_SIMULATE_NO_BATTERY, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION, + CONF_HISTORICAL_USAGE_SENSOR, CONF_MAX_CHARGE_KW, CONF_MAX_CONSECUTIVE_OFF_MINUTES, CONF_MAX_DISCHARGE_KW, @@ -34,6 +42,7 @@ CONF_SLOT_MINUTES, CONF_SOC_SOURCE, CONF_SOURCE_MODE, + CONF_SOURCE_EXPORT_PRICE, CONF_SOURCE_IMPORT_PRICE, CONF_SOURCE_PV, CONF_SOURCE_USAGE, @@ -47,6 +56,7 @@ SERVICE_REFRESH_SENSORS, SERVICE_RUN_OPTIMIZE_NOW, SERVICE_SET_TARGET, + FIXUP_PROFILE_STRICT, SOURCE_MODE_ENTITY_ADAPTER, SOURCE_MODE_NOT_USED, SOURCE_MODE_TEMPLATE, @@ -60,15 +70,23 @@ _snapshot_schema_id, ) from custom_components.wattplan.coordinator_parts import PlanningStageError, StageErrorKind +from custom_components.wattplan.historical_cost.models import ( + FLAG_GAP, + FLAG_METER_RESET, + FLAG_MISSING_IMPORT_PRICE, +) +from custom_components.wattplan.historical_cost.store import HistoricalCostStore from custom_components.wattplan.test_plan_invariants import assert_plan_invariants import pytest from homeassistant import config_entries +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONF_NAME, EntityCategory, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfEnergy, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -256,6 +274,33 @@ def _assert_valid_state(hass: HomeAssistant, entity_id: str) -> None: assert state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE), f"{entity_id} invalid" +def _set_energy_meter(hass: HomeAssistant, entity_id: str, value: float | str) -> None: + """Set a cumulative kWh energy sensor state.""" + hass.states.async_set( + entity_id, + str(value), + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + }, + ) + + +def _historical_options() -> dict[str, object]: + """Return standard enabled historical tracking options.""" + return { + CONF_PLANNING_ENABLED: False, + CONF_ACTION_EMISSION_ENABLED: False, + CONF_HISTORICAL_COST_TRACKING_ENABLED: True, + CONF_HISTORICAL_GRID_IMPORT_SENSOR: "sensor.grid_import_total", + CONF_HISTORICAL_GRID_EXPORT_SENSOR: "sensor.grid_export_total", + CONF_HISTORICAL_USAGE_SENSOR: "sensor.usage_total", + CONF_HISTORICAL_PV_SENSOR: "sensor.pv_total", + CONF_HISTORICAL_SIMULATE_NO_BATTERY: True, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION: True, + } + + async def test_runtime_diagnostic_sensors_disabled_by_default( hass: HomeAssistant, ) -> None: @@ -317,7 +362,7 @@ async def test_runtime_diagnostic_sensors_disabled_by_default( async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: - """Set up entry with one of each asset and assert projected entities have data.""" + """Set up entry with one of each asset and assert runtime entities have data.""" price_template = "{{ [0.2, 0.25, 0.3, 0.35] }}" usage_template = "{{ [1.0, 1.1, 1.0, 0.9] }}" pv_template = "{{ [0.0, 0.2, 0.3, 0.1] }}" @@ -423,34 +468,19 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: _assert_valid_state(hass, "sensor.home_status") _assert_valid_state(hass, "sensor.home_last_run") - _assert_valid_state(hass, "sensor.home_projected_cost_savings") - _assert_valid_state(hass, "sensor.home_projected_savings_percentage") _assert_valid_state(hass, "sensor.home_battery_action") _assert_valid_state(hass, "sensor.home_comfort_action") _assert_valid_state(hass, "sensor.home_optional_next_start_option") _assert_valid_state(hass, "sensor.home_optional_option_1_start") entity_registry = er.async_get(hass) - assert ( - entity_registry.async_get("sensor.home_projected_cost_savings_this_interval") - is not None - ) - assert ( - entity_registry.async_get( - "sensor.home_projected_savings_percentage_this_interval" - ) - is not None - ) - assert ( - entity_registry.async_get("sensor.home_projected_cost_savings_next_interval") - is None - ) - assert ( - entity_registry.async_get( - "sensor.home_projected_savings_percentage_next_interval" - ) - is None - ) + for entity_id in ( + "sensor.home_projected_cost_savings", + "sensor.home_projected_savings_percentage", + "sensor.home_projected_cost_savings_this_interval", + "sensor.home_projected_savings_percentage_this_interval", + ): + assert entity_registry.async_get(entity_id) is None next_option = hass.states.get("sensor.home_optional_next_start_option") assert next_option is not None @@ -470,54 +500,6 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: assert option_1.state == next_option.state assert option_1.attributes["end_timestamp"] == next_option.attributes["end_timestamp"] - savings = hass.states.get("sensor.home_projected_cost_savings") - assert savings is not None - assert float(savings.state) == 3.0 - assert savings.attributes["friendly_name"] == "Projected Cost Savings over 4h" - assert "span_start" in savings.attributes - assert "span_end" in savings.attributes - assert savings.attributes["total"] == 3.0 - assert savings.attributes["values"] == [0.5, 1.0, 1.0, 0.5] - - savings_pct = hass.states.get("sensor.home_projected_savings_percentage") - assert savings_pct is not None - assert float(savings_pct.state) == 24.0 - assert savings_pct.attributes["span_start"] == savings.attributes["span_start"] - 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" - ) - - status = hass.states.get("sensor.home_status") - assert status is not None - assert status.state == "ok" - assert status.attributes["expires_at"] == savings.attributes["span_end"] - assert status.attributes["is_stale"] is False - assert status.attributes["has_usable_plan"] is True - - projected_cost_entry = entity_registry.async_get( - "sensor.home_projected_cost_savings_this_interval" - ) - assert projected_cost_entry is not None - assert projected_cost_entry.original_name == "Projected Cost Savings over 1h" - - projected_pct_entry = entity_registry.async_get( - "sensor.home_projected_savings_percentage_this_interval" - ) - assert projected_pct_entry is not None - assert projected_pct_entry.original_name == "Projected Savings Percentage over 1h" - battery_action = hass.states.get("sensor.home_battery_action") assert battery_action is not None assert battery_action.attributes["friendly_name"] == "(battery) Action" @@ -534,10 +516,10 @@ 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( +async def test_historical_cost_tracking_seeds_without_fake_first_slot( hass: HomeAssistant, ) -> None: - """Hide implausibly large projected savings percentages while keeping components.""" + """First historical run should seed cursors without creating cost history.""" entry = MockConfigEntry( domain=DOMAIN, title="Home", @@ -548,36 +530,518 @@ async def test_projected_savings_percentage_becomes_unknown_when_extreme( CONF_SOURCES: { CONF_SOURCE_IMPORT_PRICE: { CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, - CONF_TEMPLATE: "{{ [0.2, 0.2, 0.2, 0.2] }}", + CONF_TEMPLATE: "{{ [1.0, 1.0, 1.0, 1.0] }}", }, - CONF_SOURCE_USAGE: { + }, + }, + options=_historical_options(), + ) + entry.add_to_hass(hass) + _set_energy_meter(hass, "sensor.grid_import_total", 100.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.0) + _set_energy_meter(hass, "sensor.usage_total", 200.0) + _set_energy_meter(hass, "sensor.pv_total", 50.0) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + tracker = entry.runtime_data.historical_tracker + assert tracker is not None + assert tracker.store.data["days"] == {} + actual = hass.states.get("sensor.home_historical_actual_cost_today") + assert actual is not None + assert float(actual.state) == pytest.approx(0.0) + + seeded_meters = dict(tracker.store.last_meter_values()) + await tracker.async_refresh(datetime.now(tz=UTC)) + assert tracker.store.data["days"] == {} + assert tracker.store.last_meter_values() == seeded_meters + + +async def test_historical_cost_tracking_processes_scenarios_and_entities( + hass: HomeAssistant, + freezer, +) -> None: + """Historical tracker should aggregate actual, no-battery, and self-consumption costs.""" + start = datetime(2026, 5, 24, 12, 0, tzinfo=UTC) + freezer.move_to(start + timedelta(hours=1, seconds=2)) + 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: "{{ [1.0, 1.0, 1.0, 1.0] }}", }, + CONF_SOURCE_EXPORT_PRICE: { + CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, + CONF_TEMPLATE: "{{ [0.1, 0.1, 0.1, 0.1] }}", + }, }, }, - options={ - CONF_PLANNING_ENABLED: False, - CONF_ACTION_EMISSION_ENABLED: False, + options=_historical_options(), + subentries_data=[ + config_entries.ConfigSubentryData( + subentry_id="battery_sub", + subentry_type=SUBENTRY_TYPE_BATTERY, + title="battery", + unique_id="battery:battery", + data={ + CONF_NAME: "battery", + CONF_SOC_SOURCE: "sensor.battery_soc", + CONF_CAPACITY_KWH: 10.0, + CONF_MINIMUM_KWH: 0.0, + CONF_MAX_CHARGE_KW: 3.0, + CONF_MAX_DISCHARGE_KW: 3.0, + CONF_CHARGE_EFFICIENCY: 1.0, + CONF_DISCHARGE_EFFICIENCY: 1.0, + CONF_CAN_CHARGE_FROM_GRID: False, + CONF_CAN_CHARGE_FROM_PV: True, + }, + ) + ], + ) + entry.add_to_hass(hass) + _set_energy_meter(hass, "sensor.grid_import_total", 100.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.0) + _set_energy_meter(hass, "sensor.usage_total", 200.0) + _set_energy_meter(hass, "sensor.pv_total", 50.0) + hass.states.async_set( + "sensor.battery_soc", + "1.0", + {"unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR}, + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + tracker = entry.runtime_data.historical_tracker + assert tracker is not None + tracker.store.update_metadata( + last_processed_slot=start - timedelta(hours=1), + last_meter_values={ + "grid_import": 100.0, + "grid_export": 10.0, + "usage": 200.0, + "pv": 50.0, + }, + meter_config={ + "grid_import": "sensor.grid_import_total", + "grid_export": "sensor.grid_export_total", + "usage": "sensor.usage_total", + "pv": "sensor.pv_total", + }, + ) + tracker.store.update_simulation_soc({"battery_sub": 1.0}) + + _set_energy_meter(hass, "sensor.grid_import_total", 101.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.2) + _set_energy_meter(hass, "sensor.usage_total", 201.5) + _set_energy_meter(hass, "sensor.pv_total", 51.0) + + await tracker.async_process_completed_slot(start + timedelta(hours=1, seconds=1)) + await hass.async_block_till_done() + + actual = hass.states.get("sensor.home_historical_actual_cost_today") + no_battery = hass.states.get("sensor.home_historical_no_battery_cost_today") + self_consumption = hass.states.get( + "sensor.home_historical_self_consumption_cost_today" + ) + savings = hass.states.get("sensor.home_historical_savings_vs_no_battery_today") + + assert actual is not None + assert float(actual.state) == pytest.approx(0.98) + assert actual.attributes["slots"] == 1 + assert actual.attributes["missing_slots"] == 0 + assert actual.attributes["scenario"] == "actual" + assert no_battery is not None + assert float(no_battery.state) == pytest.approx(0.5) + assert self_consumption is not None + assert float(self_consumption.state) == pytest.approx(0.0) + assert savings is not None + assert float(savings.state) == pytest.approx(-0.48) + + entity_registry = er.async_get(hass) + monthly = entity_registry.async_get( + "sensor.home_historical_actual_cost_this_month" + ) + assert monthly is not None + assert monthly.disabled + + +async def test_refresh_sensors_service_processes_historical_costs( + hass: HomeAssistant, + freezer, +) -> None: + """Manual sensor refresh should process due historical cost slots.""" + start = datetime(2026, 5, 24, 12, 0, tzinfo=UTC) + freezer.move_to(start + timedelta(hours=1, seconds=2)) + 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: "{{ [1.0, 1.0, 1.0, 1.0] }}", + }, + CONF_SOURCE_EXPORT_PRICE: { + CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, + CONF_TEMPLATE: "{{ [0.1, 0.1, 0.1, 0.1] }}", + }, + }, + }, + options=_historical_options(), + ) + entry.add_to_hass(hass) + _set_energy_meter(hass, "sensor.grid_import_total", 100.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.0) + _set_energy_meter(hass, "sensor.usage_total", 200.0) + _set_energy_meter(hass, "sensor.pv_total", 50.0) + + with patch( + "custom_components.wattplan.coordinator.optimize", + side_effect=_fake_optimize, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await entry.runtime_data.coordinator.async_plan(trigger=CycleTrigger.SERVICE) + + tracker = entry.runtime_data.historical_tracker + assert tracker is not None + tracker.store.update_metadata( + last_processed_slot=start - timedelta(hours=1), + last_meter_values={ + "grid_import": 100.0, + "grid_export": 10.0, + "usage": 200.0, + "pv": 50.0, + }, + meter_config={ + "grid_import": "sensor.grid_import_total", + "grid_export": "sensor.grid_export_total", + "usage": "sensor.usage_total", + "pv": "sensor.pv_total", + }, + ) + + _set_energy_meter(hass, "sensor.grid_import_total", 101.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.2) + _set_energy_meter(hass, "sensor.usage_total", 201.5) + _set_energy_meter(hass, "sensor.pv_total", 51.0) + + await hass.services.async_call(DOMAIN, SERVICE_REFRESH_SENSORS, {}, blocking=True) + await hass.async_block_till_done() + + actual = hass.states.get("sensor.home_historical_actual_cost_today") + assert actual is not None + assert float(actual.state) == pytest.approx(0.98) + + +async def test_historical_cost_uses_cached_planner_prices_after_forecast_rolls( + hass: HomeAssistant, + freezer, +) -> None: + """Completed historical slots should use retained prices from successful plans.""" + start = datetime(2026, 5, 24, 12, 0, tzinfo=UTC) + freezer.move_to(start + timedelta(seconds=2)) + + def _points(first: datetime, values: list[float]) -> list[dict[str, object]]: + return [ + { + "start": (first + timedelta(hours=index)).isoformat(), + "value": value, + } + for index, value in enumerate(values) + ] + + hass.states.async_set( + "sensor.import_price_forecast", + "ok", + {"prices": _points(start, [1.0, 1.1, 1.2, 1.3])}, + ) + hass.states.async_set( + "sensor.export_price_forecast", + "ok", + {"prices": _points(start, [0.1, 0.11, 0.12, 0.13])}, + ) + 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_ENTITY_ADAPTER, + "entity_id": "sensor.import_price_forecast", + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: "prices", + CONF_TIME_KEY: "start", + CONF_VALUE_KEY: "value", + CONF_FIXUP_PROFILE: FIXUP_PROFILE_STRICT, + }, + CONF_SOURCE_EXPORT_PRICE: { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + "entity_id": "sensor.export_price_forecast", + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: "prices", + CONF_TIME_KEY: "start", + CONF_VALUE_KEY: "value", + CONF_FIXUP_PROFILE: FIXUP_PROFILE_STRICT, + }, + }, }, + options=_historical_options(), ) entry.add_to_hass(hass) + _set_energy_meter(hass, "sensor.grid_import_total", 100.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.0) + _set_energy_meter(hass, "sensor.usage_total", 200.0) + _set_energy_meter(hass, "sensor.pv_total", 50.0) with patch( "custom_components.wattplan.coordinator.optimize", - side_effect=_fake_optimize_with_extreme_savings, + side_effect=_fake_optimize, ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + await entry.runtime_data.coordinator.async_plan(trigger=CycleTrigger.SERVICE) + + tracker = entry.runtime_data.historical_tracker + assert tracker is not None + assert tracker.store.cached_price(start, "import") == pytest.approx(1.0) + assert tracker.store.cached_price(start, "export") == pytest.approx(0.1) + + tracker.store.update_metadata( + last_processed_slot=start - timedelta(hours=1), + last_meter_values={ + "grid_import": 100.0, + "grid_export": 10.0, + "usage": 200.0, + "pv": 50.0, + }, + meter_config={ + "grid_import": "sensor.grid_import_total", + "grid_export": "sensor.grid_export_total", + "usage": "sensor.usage_total", + "pv": "sensor.pv_total", + }, + ) + hass.states.async_set( + "sensor.import_price_forecast", + "ok", + {"prices": _points(start + timedelta(hours=1), [2.0, 2.1, 2.2, 2.3])}, + ) + hass.states.async_set( + "sensor.export_price_forecast", + "ok", + {"prices": _points(start + timedelta(hours=1), [0.2, 0.21, 0.22, 0.23])}, + ) + _set_energy_meter(hass, "sensor.grid_import_total", 101.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.2) + _set_energy_meter(hass, "sensor.usage_total", 201.5) + _set_energy_meter(hass, "sensor.pv_total", 51.0) - 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 + await tracker.async_process_completed_slot(start + timedelta(hours=1, seconds=1)) + await hass.async_block_till_done() + + day = tracker.store.data["days"]["2026-05-24"] + assert day["import_price"] == [1.0] + assert day["export_price"] == [0.1] + assert day["flags"] == [0] + actual = hass.states.get("sensor.home_historical_actual_cost_today") + assert actual is not None + assert float(actual.state) == pytest.approx(0.98) + + +async def test_scheduled_tick_processes_historical_costs( + hass: HomeAssistant, + freezer, +) -> None: + """Scheduled coordinator ticks should process due historical cost slots.""" + start = datetime(2026, 5, 24, 12, 0, tzinfo=UTC) + freezer.move_to(start + timedelta(hours=1, seconds=2)) + 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: "{{ [1.0, 1.0, 1.0, 1.0] }}", + }, + CONF_SOURCE_EXPORT_PRICE: { + CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, + CONF_TEMPLATE: "{{ [0.1, 0.1, 0.1, 0.1] }}", + }, + }, + }, + options=_historical_options(), + ) + entry.add_to_hass(hass) + _set_energy_meter(hass, "sensor.grid_import_total", 100.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.0) + _set_energy_meter(hass, "sensor.usage_total", 200.0) + _set_energy_meter(hass, "sensor.pv_total", 50.0) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + tracker = entry.runtime_data.historical_tracker + assert tracker is not None + tracker.store.update_metadata( + last_processed_slot=start - timedelta(hours=1), + last_meter_values={ + "grid_import": 100.0, + "grid_export": 10.0, + "usage": 200.0, + "pv": 50.0, + }, + meter_config={ + "grid_import": "sensor.grid_import_total", + "grid_export": "sensor.grid_export_total", + "usage": "sensor.usage_total", + "pv": "sensor.pv_total", + }, + ) + + _set_energy_meter(hass, "sensor.grid_import_total", 101.0) + _set_energy_meter(hass, "sensor.grid_export_total", 10.2) + _set_energy_meter(hass, "sensor.usage_total", 201.5) + _set_energy_meter(hass, "sensor.pv_total", 51.0) + + await entry.runtime_data.coordinator.async_tick(trigger=CycleTrigger.SCHEDULE) + await hass.async_block_till_done() + + actual = hass.states.get("sensor.home_historical_actual_cost_today") + assert actual is not None + assert float(actual.state) == pytest.approx(0.98) + + +async def test_historical_cost_tracking_flags_meter_reset_and_missing_price( + hass: HomeAssistant, + freezer, +) -> None: + """Invalid deltas and missing prices should create gap records instead of costs.""" + start = datetime(2026, 5, 24, 12, 0, tzinfo=UTC) + freezer.move_to(start + timedelta(hours=1, seconds=2)) + 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_NOT_USED}, + }, + }, + options={ + **_historical_options(), + CONF_HISTORICAL_GRID_EXPORT_SENSOR: None, + CONF_HISTORICAL_PV_SENSOR: None, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION: False, + }, + ) + entry.add_to_hass(hass) + _set_energy_meter(hass, "sensor.grid_import_total", 100.0) + _set_energy_meter(hass, "sensor.usage_total", 200.0) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + tracker = entry.runtime_data.historical_tracker + assert tracker is not None + tracker.store.update_metadata( + last_processed_slot=start - timedelta(hours=1), + last_meter_values={ + "grid_import": 100.0, + "grid_export": 0.0, + "usage": 200.0, + "pv": 0.0, + }, + meter_config={ + "grid_import": "sensor.grid_import_total", + "grid_export": None, + "usage": "sensor.usage_total", + "pv": None, + }, + ) + + _set_energy_meter(hass, "sensor.grid_import_total", 99.0) + _set_energy_meter(hass, "sensor.usage_total", 201.0) + + await tracker.async_process_completed_slot(start + timedelta(hours=1, seconds=1)) + + day = tracker.store.data["days"]["2026-05-24"] + assert day["flags"] == [FLAG_METER_RESET | FLAG_MISSING_IMPORT_PRICE] + assert hass.states.get("sensor.home_historical_actual_cost_today").state in { + STATE_UNAVAILABLE, + STATE_UNKNOWN, + } + + +async def test_historical_cost_store_prunes_old_days(hass: HomeAssistant) -> None: + """Historical store should keep only the fixed local-day retention window.""" + store = HistoricalCostStore( + hass, + entry_id="history-entry", + slot_minutes=60, + currency="DKK", + ) + await store.async_load() + store.data["days"] = { + "2026-03-01": {"starts": []}, + "2026-05-01": {"starts": []}, + } + store.data["price_cache"] = { + "2026-03-01T12:00:00Z": {"import_price": 1.0, "export_price": 0.1}, + "2026-05-01T12:00:00Z": {"import_price": 1.0, "export_price": 0.1}, + "2026-05-25T12:00:00Z": {"import_price": 1.0, "export_price": 0.1}, + } + + store.prune(datetime(2026, 5, 24, 12, 0, tzinfo=UTC)) + + assert "2026-03-01" not in store.data["days"] + assert "2026-05-01" in store.data["days"] + assert "2026-03-01T12:00:00Z" not in store.data["price_cache"] + assert "2026-05-01T12:00:00Z" in store.data["price_cache"] + assert "2026-05-25T12:00:00Z" in store.data["price_cache"] + + +async def test_historical_cost_store_migrates_missing_price_cache( + hass: HomeAssistant, +) -> None: + """Older historical stores should gain an empty retained price cache.""" + store = HistoricalCostStore( + hass, + entry_id="history-entry", + slot_minutes=60, + currency="DKK", + ) + + migrated = store._migrate( + { + "version": 1, + "slot_minutes": 60, + "currency": "DKK", + "days": {}, + "last_meter_values": {}, + "meter_config": {}, + "simulation_state": {}, + } + ) + + assert migrated["price_cache"] == {} async def test_battery_action_sensor_uses_source_specific_charge_state( @@ -1746,3 +2210,67 @@ async def test_button_optimize_raises_when_already_running(hass: HomeAssistant) async with coordinator._plan_lock: with pytest.raises(HAServiceValidationError): await coordinator.async_plan(trigger=CycleTrigger.SERVICE) + + +async def test_historical_cost_tracking_records_each_skipped_slot( + hass: HomeAssistant, + freezer, +) -> None: + """Skipped historical intervals should create one gap record per slot.""" + start = datetime(2026, 5, 24, 12, 0, tzinfo=UTC) + freezer.move_to(start + timedelta(hours=3, seconds=2)) + 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: "{{ [1.0, 1.0, 1.0, 1.0] }}", + }, + }, + }, + options={ + **_historical_options(), + CONF_HISTORICAL_GRID_EXPORT_SENSOR: None, + CONF_HISTORICAL_PV_SENSOR: None, + CONF_HISTORICAL_SIMULATE_SELF_CONSUMPTION: False, + }, + ) + entry.add_to_hass(hass) + _set_energy_meter(hass, "sensor.grid_import_total", 100.0) + _set_energy_meter(hass, "sensor.usage_total", 200.0) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + tracker = entry.runtime_data.historical_tracker + assert tracker is not None + tracker.store.update_metadata( + last_processed_slot=start - timedelta(hours=1), + last_meter_values={ + "grid_import": 100.0, + "grid_export": 0.0, + "usage": 200.0, + "pv": 0.0, + }, + meter_config={ + "grid_import": "sensor.grid_import_total", + "grid_export": None, + "usage": "sensor.usage_total", + "pv": None, + }, + ) + + await tracker.async_process_completed_slot(start + timedelta(hours=3, seconds=1)) + + day = tracker.store.data["days"]["2026-05-24"] + assert day["starts"] == [ + "2026-05-24T12:00:00Z", + "2026-05-24T13:00:00Z", + "2026-05-24T14:00:00Z", + ] + assert day["flags"] == [FLAG_GAP, FLAG_GAP, FLAG_GAP] + assert tracker.store.last_processed_slot() == start + timedelta(hours=2) diff --git a/tests/optimizer/test_historical_cost_simulations.py b/tests/optimizer/test_historical_cost_simulations.py new file mode 100644 index 0000000..a63698e --- /dev/null +++ b/tests/optimizer/test_historical_cost_simulations.py @@ -0,0 +1,96 @@ +"""Tests for pure historical cost simulation helpers.""" + +from custom_components.wattplan.historical_cost.simulations import ( + BatterySimulationConfig, + no_battery_cost, + simulate_self_consumption_slot, +) +import pytest + + +def test_no_battery_cost_uses_usage_pv_and_prices() -> None: + """No-battery scenario should recompute flows from usage and PV facts.""" + assert no_battery_cost( + usage=1.5, + pv=1.0, + import_price=2.0, + export_price=0.5, + ) == pytest.approx(1.0) + assert no_battery_cost( + usage=0.5, + pv=1.0, + import_price=2.0, + export_price=0.5, + ) == pytest.approx(-0.25) + + +def test_self_consumption_uses_batteries_in_configured_order() -> None: + """Self-consumption should charge and discharge batteries in configured order.""" + batteries = [ + BatterySimulationConfig( + subentry_id="first", + minimum_kwh=0.0, + capacity_kwh=2.0, + max_charge_kwh=1.0, + max_discharge_kwh=1.0, + charge_efficiency=1.0, + discharge_efficiency=1.0, + can_charge_from_pv=True, + ), + BatterySimulationConfig( + subentry_id="second", + minimum_kwh=0.0, + capacity_kwh=2.0, + max_charge_kwh=1.0, + max_discharge_kwh=1.0, + charge_efficiency=1.0, + discharge_efficiency=1.0, + can_charge_from_pv=True, + ), + ] + + charged = simulate_self_consumption_slot( + usage=0.0, + pv=1.5, + batteries=batteries, + soc_by_battery={"first": 0.0, "second": 0.0}, + ) + + assert charged.grid_export == pytest.approx(0.0) + assert charged.soc_by_battery["first"] == pytest.approx(1.0) + assert charged.soc_by_battery["second"] == pytest.approx(0.5) + + discharged = simulate_self_consumption_slot( + usage=1.5, + pv=0.0, + batteries=batteries, + soc_by_battery=charged.soc_by_battery, + ) + + assert discharged.grid_import == pytest.approx(0.0) + assert discharged.soc_by_battery["first"] == pytest.approx(0.0) + assert discharged.soc_by_battery["second"] == pytest.approx(0.0) + + +def test_self_consumption_discharge_limit_is_delivered_energy() -> None: + """Discharge power limit should cap delivered energy, not SoC draw.""" + result = simulate_self_consumption_slot( + usage=1.0, + pv=0.0, + batteries=[ + BatterySimulationConfig( + subentry_id="battery", + minimum_kwh=0.0, + capacity_kwh=2.0, + max_charge_kwh=1.0, + max_discharge_kwh=1.0, + charge_efficiency=1.0, + discharge_efficiency=0.8, + can_charge_from_pv=True, + ) + ], + soc_by_battery={"battery": 2.0}, + ) + + assert result.grid_import == pytest.approx(0.0) + assert result.soc_by_battery["battery"] == pytest.approx(0.75)