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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
4 changes: 4 additions & 0 deletions custom_components/wattplan/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))


Expand All @@ -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)


Expand Down
7 changes: 7 additions & 0 deletions custom_components/wattplan/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions custom_components/wattplan/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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():
Expand All @@ -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
)
Expand Down Expand Up @@ -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."""
Expand Down
37 changes: 33 additions & 4 deletions custom_components/wattplan/entry_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Loading
Loading