diff --git a/.gitignore b/.gitignore index a83d584..e82d0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ venv/ .DS_Store .envrc .superpowers/ +**/superpowers/ .last_config configs/rates/rates_cache.yaml @@ -24,3 +25,7 @@ span_panel_simulator/docs/roadmap/ **/roadmap/ span_panel_simulator/docs/2026-03-*-*.md span_panel_simulator/docs/cost-and-montecarlo-specs.md + +# Rate cache contains API keys — never commit +configs/rates/ +configs/*_history.db diff --git a/docs/superpowers/plans/2026-03-28-opower-before-cost.md b/docs/superpowers/plans/2026-03-28-opower-before-cost.md new file mode 100644 index 0000000..a424674 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-opower-before-cost.md @@ -0,0 +1,1177 @@ +# Opower Before Cost Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Use actual billed cost from the utility (via HA's Opower integration) for the Before cost in modeling, falling back to URDB calculation when opower is not available. + +**Architecture:** A new `ha_api/opower.py` module discovers opower accounts and fetches daily cost statistics via existing HA WebSocket APIs. The rate cache stores the selected opower account. The modeling route checks for opower data before falling back to URDB. The modeling view conditionally shows a "Billing Data" section. + +**Tech Stack:** Python 3.14, aiohttp (existing), pytest with AsyncMock. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-03-28-opower-before-cost-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `src/span_panel_simulator/ha_api/opower.py` | Create | Opower account discovery and daily cost fetch | +| `tests/test_ha_api/__init__.py` | Create | Test package init | +| `tests/test_ha_api/test_opower.py` | Create | Opower discovery and cost fetch tests | +| `src/span_panel_simulator/rates/cache.py` | Modify | Add opower account get/set methods | +| `tests/test_rates/test_cache.py` | Modify | Add opower account persistence tests | +| `src/span_panel_simulator/dashboard/routes.py` | Modify | Add opower endpoints, modify _attach_costs | +| `src/span_panel_simulator/dashboard/templates/partials/modeling_view.html` | Modify | Conditional Billing Data section, cost display source handling | + +--- + +## Phase 1: Opower Discovery and Cost Fetch + +### Task 1: Opower Discovery + +**Files:** +- Create: `src/span_panel_simulator/ha_api/opower.py` +- Create: `tests/test_ha_api/__init__.py` +- Create: `tests/test_ha_api/test_opower.py` + +- [ ] **Step 1: Create test package and write failing tests** + +```bash +mkdir -p tests/test_ha_api +``` + +Create `tests/test_ha_api/__init__.py`: + +```python +``` + +Create `tests/test_ha_api/test_opower.py`: + +```python +"""Tests for opower discovery and cost fetch via HA API.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from span_panel_simulator.ha_api.opower import ( + OpowerAccount, + async_discover_opower, + async_get_opower_cost, +) + + +def _make_client( + config_entries: list[dict], + devices: list[dict], + entities: list[dict], + statistics: dict | None = None, +) -> AsyncMock: + """Create a mock HAClient with canned responses.""" + client = AsyncMock() + client._ws_command_list = AsyncMock(side_effect=_ws_list_router(config_entries, devices, entities)) + if statistics is not None: + client.async_get_statistics = AsyncMock(return_value=statistics) + return client + + +def _ws_list_router( + config_entries: list[dict], + devices: list[dict], + entities: list[dict], +): + """Return a side_effect function that routes WS list commands.""" + async def route(payload: dict) -> list[dict]: + cmd_type = payload.get("type", "") + if cmd_type == "config_entries/get": + return config_entries + if cmd_type == "config/device_registry/list": + return devices + if cmd_type == "config/entity_registry/list": + return entities + return [] + return route + + +# -- Fixtures: realistic HA registry data -------------------------------- + +OPOWER_CONFIG_ENTRY = { + "entry_id": "opower_entry_1", + "domain": "opower", + "title": "Pacific Gas and Electric Company (PG&E)", +} + +ELEC_DEVICE = { + "id": "device_elec_1", + "name": "ELEC account 3021618479", + "config_entries": ["opower_entry_1"], + "identifiers": [["opower", "pge_elec_3021618479"]], +} + +GAS_DEVICE = { + "id": "device_gas_1", + "name": "GAS account 3021618302", + "config_entries": ["opower_entry_1"], + "identifiers": [["opower", "pge_gas_3021618302"]], +} + +ELEC_ENTITIES = [ + { + "entity_id": "sensor.opower_pge_elec_cost_to_date", + "device_id": "device_elec_1", + "original_device_class": "monetary", + "entity_category": None, + }, + { + "entity_id": "sensor.opower_pge_elec_usage_to_date", + "device_id": "device_elec_1", + "original_device_class": "energy", + "entity_category": None, + }, + { + "entity_id": "sensor.opower_pge_elec_forecasted_cost", + "device_id": "device_elec_1", + "original_device_class": "monetary", + "entity_category": None, + }, + { + "entity_id": "sensor.opower_pge_elec_forecasted_usage", + "device_id": "device_elec_1", + "original_device_class": "energy", + "entity_category": None, + }, +] + +GAS_ENTITIES = [ + { + "entity_id": "sensor.opower_pge_gas_cost_to_date", + "device_id": "device_gas_1", + "original_device_class": "monetary", + "entity_category": None, + }, + { + "entity_id": "sensor.opower_pge_gas_usage_to_date", + "device_id": "device_gas_1", + "original_device_class": "energy", + "entity_category": None, + }, +] + + +class TestAsyncDiscoverOpower: + """Discover opower ELEC accounts from HA registries.""" + + @pytest.mark.asyncio + async def test_finds_elec_account(self) -> None: + client = _make_client( + config_entries=[OPOWER_CONFIG_ENTRY], + devices=[ELEC_DEVICE, GAS_DEVICE], + entities=[*ELEC_ENTITIES, *GAS_ENTITIES], + ) + accounts = await async_discover_opower(client) + assert len(accounts) == 1 + assert accounts[0].device_id == "device_elec_1" + assert accounts[0].utility_name == "Pacific Gas and Electric Company (PG&E)" + assert accounts[0].account_number == "3021618479" + assert accounts[0].cost_entity_id == "sensor.opower_pge_elec_cost_to_date" + assert accounts[0].usage_entity_id == "sensor.opower_pge_elec_usage_to_date" + + @pytest.mark.asyncio + async def test_ignores_gas_accounts(self) -> None: + client = _make_client( + config_entries=[OPOWER_CONFIG_ENTRY], + devices=[GAS_DEVICE], + entities=[*GAS_ENTITIES], + ) + accounts = await async_discover_opower(client) + assert accounts == [] + + @pytest.mark.asyncio + async def test_no_opower_installed(self) -> None: + client = _make_client( + config_entries=[{"entry_id": "other", "domain": "met_eireann", "title": "Weather"}], + devices=[], + entities=[], + ) + accounts = await async_discover_opower(client) + assert accounts == [] + + @pytest.mark.asyncio + async def test_opower_installed_no_devices(self) -> None: + client = _make_client( + config_entries=[OPOWER_CONFIG_ENTRY], + devices=[], + entities=[], + ) + accounts = await async_discover_opower(client) + assert accounts == [] + + @pytest.mark.asyncio + async def test_multiple_elec_accounts(self) -> None: + elec2 = { + "id": "device_elec_2", + "name": "ELEC account 9999999999", + "config_entries": ["opower_entry_1"], + "identifiers": [["opower", "pge_elec_9999999999"]], + } + elec2_entities = [ + { + "entity_id": "sensor.opower_pge_elec_cost_to_date_2", + "device_id": "device_elec_2", + "original_device_class": "monetary", + "entity_category": None, + }, + { + "entity_id": "sensor.opower_pge_elec_usage_to_date_2", + "device_id": "device_elec_2", + "original_device_class": "energy", + "entity_category": None, + }, + ] + client = _make_client( + config_entries=[OPOWER_CONFIG_ENTRY], + devices=[ELEC_DEVICE, elec2], + entities=[*ELEC_ENTITIES, *elec2_entities], + ) + accounts = await async_discover_opower(client) + assert len(accounts) == 2 + + +class TestAsyncGetOpowerCost: + """Fetch and sum daily cost statistics.""" + + @pytest.mark.asyncio + async def test_sums_daily_cost(self) -> None: + stats = { + "sensor.opower_pge_elec_cost_to_date": [ + {"start": "2026-01-01T00:00:00Z", "change": 3.50}, + {"start": "2026-01-02T00:00:00Z", "change": 4.20}, + {"start": "2026-01-03T00:00:00Z", "change": 2.80}, + ] + } + client = _make_client([], [], [], statistics=stats) + result = await async_get_opower_cost( + client, "sensor.opower_pge_elec_cost_to_date", + "2026-01-01T00:00:00Z", "2026-01-04T00:00:00Z", + ) + assert result is not None + assert result.total_cost == pytest.approx(10.50) + assert result.days_with_data == 3 + + @pytest.mark.asyncio + async def test_no_data_returns_none(self) -> None: + stats: dict = {"sensor.opower_pge_elec_cost_to_date": []} + client = _make_client([], [], [], statistics=stats) + result = await async_get_opower_cost( + client, "sensor.opower_pge_elec_cost_to_date", + "2026-01-01T00:00:00Z", "2026-01-04T00:00:00Z", + ) + assert result is None + + @pytest.mark.asyncio + async def test_entity_not_in_response(self) -> None: + stats: dict = {} + client = _make_client([], [], [], statistics=stats) + result = await async_get_opower_cost( + client, "sensor.opower_pge_elec_cost_to_date", + "2026-01-01T00:00:00Z", "2026-01-04T00:00:00Z", + ) + assert result is None + + @pytest.mark.asyncio + async def test_skips_entries_without_change(self) -> None: + stats = { + "sensor.opower_pge_elec_cost_to_date": [ + {"start": "2026-01-01T00:00:00Z", "change": 3.50}, + {"start": "2026-01-02T00:00:00Z"}, # no change field + {"start": "2026-01-03T00:00:00Z", "change": None}, + {"start": "2026-01-04T00:00:00Z", "change": 2.00}, + ] + } + client = _make_client([], [], [], statistics=stats) + result = await async_get_opower_cost( + client, "sensor.opower_pge_elec_cost_to_date", + "2026-01-01T00:00:00Z", "2026-01-05T00:00:00Z", + ) + assert result is not None + assert result.total_cost == pytest.approx(5.50) + assert result.days_with_data == 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_ha_api/test_opower.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'span_panel_simulator.ha_api.opower'` + +- [ ] **Step 3: Write the opower module** + +Create `src/span_panel_simulator/ha_api/opower.py`: + +```python +"""Opower account discovery and cost fetch via HA API. + +Composes existing HAClient methods to find opower ELEC accounts +and retrieve daily billed cost statistics from the HA recorder. +Does not modify the HAClient itself. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from span_panel_simulator.ha_api.client import HAClient + +_LOG = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class OpowerAccount: + """An opower ELEC account discovered from HA.""" + + device_id: str + utility_name: str + account_number: str + cost_entity_id: str + usage_entity_id: str + + +@dataclass(frozen=True) +class OpowerCostResult: + """Result of summing daily opower cost statistics.""" + + total_cost: float + days_with_data: int + + +async def async_discover_opower(client: HAClient) -> list[OpowerAccount]: + """Find opower ELEC accounts via HA config entries and registries. + + Returns an empty list when opower is not installed or has no + ELEC accounts. + """ + # 1. Find opower config entries + config_entries = await client._ws_command_list({"type": "config_entries/get"}) + opower_entry_ids: set[str] = set() + opower_titles: dict[str, str] = {} # entry_id -> title (utility name) + for entry in config_entries: + if entry.get("domain") == "opower": + entry_id = str(entry.get("entry_id", "")) + opower_entry_ids.add(entry_id) + opower_titles[entry_id] = str(entry.get("title", "")) + + if not opower_entry_ids: + return [] + + # 2. Find ELEC devices belonging to opower entries + devices = await client._ws_command_list({"type": "config/device_registry/list"}) + elec_devices: list[tuple[str, str, str]] = [] # (device_id, utility_name, account_number) + for dev in devices: + dev_entries = dev.get("config_entries", []) + if not isinstance(dev_entries, list): + continue + matching_entry = None + for eid in dev_entries: + if eid in opower_entry_ids: + matching_entry = str(eid) + break + if matching_entry is None: + continue + + name = str(dev.get("name", "")) + if "ELEC" not in name.upper(): + continue + + device_id = str(dev.get("id", "")) + utility_name = opower_titles.get(matching_entry, "") + # Extract account number from device name like "ELEC account 3021618479" + parts = name.split() + account_number = parts[-1] if len(parts) >= 2 else name + + elec_devices.append((device_id, utility_name, account_number)) + + if not elec_devices: + return [] + + # 3. Find cost and usage entities for each ELEC device + entities = await client._ws_command_list({"type": "config/entity_registry/list"}) + + accounts: list[OpowerAccount] = [] + for device_id, utility_name, account_number in elec_devices: + cost_entity_id = "" + usage_entity_id = "" + for ent in entities: + if ent.get("device_id") != device_id: + continue + ent_id = str(ent.get("entity_id", "")) + device_class = str(ent.get("original_device_class", "")) + # Cost entity: monetary class with "cost_to_date" in name + if device_class == "monetary" and "cost_to_date" in ent_id: + cost_entity_id = ent_id + # Usage entity: energy class with "usage_to_date" in name + elif device_class == "energy" and "usage_to_date" in ent_id: + usage_entity_id = ent_id + + if cost_entity_id and usage_entity_id: + accounts.append(OpowerAccount( + device_id=device_id, + utility_name=utility_name, + account_number=account_number, + cost_entity_id=cost_entity_id, + usage_entity_id=usage_entity_id, + )) + + return accounts + + +async def async_get_opower_cost( + client: HAClient, + cost_entity_id: str, + start_time: str, + end_time: str, +) -> OpowerCostResult | None: + """Sum daily billed cost from opower statistics over a date range. + + Returns an OpowerCostResult with total cost and days covered, + or None if no data is available. Uses the ``change`` field from + daily statistics, which represents the cost delta for each day + (opower cost entities use ``state_class: total``). + """ + stats = await client.async_get_statistics( + [cost_entity_id], + period="day", + start_time=start_time, + end_time=end_time, + ) + entries = stats.get(cost_entity_id, []) + if not entries: + return None + + total = 0.0 + days_with_data = 0 + for entry in entries: + change = entry.get("change") + if change is not None: + total += float(change) + days_with_data += 1 + + if days_with_data == 0: + return None + + return OpowerCostResult(total_cost=total, days_with_data=days_with_data) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_ha_api/test_opower.py -v` +Expected: All 9 tests PASS + +- [ ] **Step 5: Run mypy** + +Run: `uv run mypy src/span_panel_simulator/ha_api/opower.py` +Expected: No errors + +- [ ] **Step 6: Commit** + +```bash +git add src/span_panel_simulator/ha_api/opower.py tests/test_ha_api/ +git commit -m "Add opower discovery and daily cost fetch via HA API" +``` + +--- + +## Phase 2: Rate Cache Extension + +### Task 2: Opower Account Persistence + +**Files:** +- Modify: `src/span_panel_simulator/rates/cache.py` +- Modify: `tests/test_rates/test_cache.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_rates/test_cache.py`: + +```python +class TestOpowerAccount: + """Opower account selection persistence.""" + + def test_no_opower_account_returns_none(self, tmp_path: Path) -> None: + cache = RateCache(tmp_path / "rates_cache.yaml") + assert cache.get_opower_account() is None + + def test_set_and_get_opower_account(self, tmp_path: Path) -> None: + cache = RateCache(tmp_path / "rates_cache.yaml") + cache.set_opower_account( + device_id="device_elec_1", + utility_name="PG&E", + account_number="3021618479", + cost_entity_id="sensor.opower_pge_elec_cost_to_date", + usage_entity_id="sensor.opower_pge_elec_usage_to_date", + ) + account = cache.get_opower_account() + assert account is not None + assert account["device_id"] == "device_elec_1" + assert account["utility_name"] == "PG&E" + assert account["cost_entity_id"] == "sensor.opower_pge_elec_cost_to_date" + + def test_opower_account_persists(self, tmp_path: Path) -> None: + path = tmp_path / "rates_cache.yaml" + cache1 = RateCache(path) + cache1.set_opower_account( + device_id="device_elec_1", + utility_name="PG&E", + account_number="3021618479", + cost_entity_id="sensor.opower_pge_elec_cost_to_date", + usage_entity_id="sensor.opower_pge_elec_usage_to_date", + ) + cache2 = RateCache(path) + account = cache2.get_opower_account() + assert account is not None + assert account["device_id"] == "device_elec_1" + + def test_clear_opower_account(self, tmp_path: Path) -> None: + cache = RateCache(tmp_path / "rates_cache.yaml") + cache.set_opower_account( + device_id="d1", utility_name="U", account_number="A", + cost_entity_id="c", usage_entity_id="u", + ) + assert cache.get_opower_account() is not None + cache.clear_opower_account() + assert cache.get_opower_account() is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_rates/test_cache.py::TestOpowerAccount -v` +Expected: FAIL with `AttributeError: 'RateCache' object has no attribute 'get_opower_account'` + +- [ ] **Step 3: Add opower account methods to RateCache** + +Add to `src/span_panel_simulator/rates/cache.py`, after the OpenEI configuration section: + +```python + # -- Opower account selection ---------------------------------------- + + def get_opower_account(self) -> dict[str, str] | None: + """Return the saved opower account selection, or None.""" + account = self._data.get("opower_account") + if not account or not isinstance(account, dict): + return None + if not account.get("device_id"): + return None + return { + "device_id": str(account.get("device_id", "")), + "utility_name": str(account.get("utility_name", "")), + "account_number": str(account.get("account_number", "")), + "cost_entity_id": str(account.get("cost_entity_id", "")), + "usage_entity_id": str(account.get("usage_entity_id", "")), + } + + def set_opower_account( + self, + device_id: str, + utility_name: str, + account_number: str, + cost_entity_id: str, + usage_entity_id: str, + ) -> None: + """Save the opower account selection.""" + self._data["opower_account"] = { + "device_id": device_id, + "utility_name": utility_name, + "account_number": account_number, + "cost_entity_id": cost_entity_id, + "usage_entity_id": usage_entity_id, + } + self._save() + + def clear_opower_account(self) -> None: + """Remove the saved opower account selection.""" + self._data.pop("opower_account", None) + self._save() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_rates/test_cache.py -v` +Expected: All tests PASS (existing + 4 new) + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/rates/cache.py tests/test_rates/test_cache.py +git commit -m "Add opower account persistence to rate cache" +``` + +--- + +## Phase 3: API Endpoints and Modeling Integration + +### Task 3: Opower API Endpoints + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/routes.py` + +- [ ] **Step 1: Add opower route handlers** + +Add imports near top of `routes.py`: + +```python +from span_panel_simulator.ha_api.opower import ( + async_discover_opower, + async_get_opower_cost, +) +``` + +Add route handlers (near the other rate handlers): + +```python +async def handle_get_opower_accounts(request: web.Request) -> web.Response: + """GET /rates/opower-accounts — discover opower ELEC accounts from HA.""" + ctx = _ctx(request) + if ctx.ha_client is None: + return web.json_response([]) + from span_panel_simulator.ha_api.client import HAClient + + if not isinstance(ctx.ha_client, HAClient): + return web.json_response([]) + try: + accounts = await async_discover_opower(ctx.ha_client) + except Exception: + _LOGGER.exception("Failed to discover opower accounts") + return web.json_response([]) + return web.json_response([ + { + "device_id": a.device_id, + "utility_name": a.utility_name, + "account_number": a.account_number, + "cost_entity_id": a.cost_entity_id, + "usage_entity_id": a.usage_entity_id, + } + for a in accounts + ]) + + +async def handle_get_opower_account(request: web.Request) -> web.Response: + """GET /rates/opower-account — get saved opower account.""" + account = _rate_cache(request).get_opower_account() + if account is None: + return web.json_response({"configured": False}) + return web.json_response({**account, "configured": True}) + + +async def handle_put_opower_account(request: web.Request) -> web.Response: + """PUT /rates/opower-account — save selected opower account.""" + body = await request.json() + device_id = body.get("device_id", "").strip() + if not device_id: + return web.json_response({"error": "device_id is required"}, status=400) + _rate_cache(request).set_opower_account( + device_id=device_id, + utility_name=body.get("utility_name", ""), + account_number=body.get("account_number", ""), + cost_entity_id=body.get("cost_entity_id", ""), + usage_entity_id=body.get("usage_entity_id", ""), + ) + return web.json_response({"ok": True}) +``` + +- [ ] **Step 2: Register the routes in setup_routes** + +Add after the existing rate routes: + +```python + # Opower account management + app.router.add_get("/rates/opower-accounts", handle_get_opower_accounts) + app.router.add_get("/rates/opower-account", handle_get_opower_account) + app.router.add_put("/rates/opower-account", handle_put_opower_account) +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/span_panel_simulator/dashboard/routes.py +git commit -m "Add opower account discovery and selection endpoints" +``` + +--- + +### Task 4: Modify _attach_costs for Opower Before Cost + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/routes.py` + +- [ ] **Step 1: Update handle_modeling_data to pass HA client** + +Change `handle_modeling_data` to pass the HA client through: + +```python +async def handle_modeling_data(request: web.Request) -> web.Response: + """Return time-series for Before/After energy comparison.""" + ctx = _ctx(request) + horizon_key = request.query.get("horizon", "1mo") + horizon_hours = _HORIZON_MAP.get(horizon_key, 730) + + config_file = resolve_modeling_config_filename(ctx, request.query.get("config")) + result = await ctx.get_modeling_data(horizon_hours, config_file) + if result is None: + return web.json_response({"error": "No running simulation"}, status=503) + if "error" in result: + return web.json_response(result, status=400) + + # Attach cost data if rate cache is available + cache = _rate_cache(request) + proposed_label = request.query.get("proposed_rate_label") + await _attach_costs(result, cache, proposed_label, ctx.ha_client) + + return web.json_response(result) +``` + +- [ ] **Step 2: Update _attach_costs to check opower first** + +Replace the existing `_attach_costs` function: + +```python +async def _attach_costs( + result: dict[str, Any], + cache: RateCache, + proposed_rate_label: str | None, + ha_client: Any, +) -> None: + """Add before_costs and after_costs to a modeling result dict. + + Before cost priority: + 1. Opower actual billed cost (if HA + opower account configured) + 2. URDB calculation against recorder power arrays + After cost: always URDB. + """ + tz_str: str = result["time_zone"] + ts_list: list[int] = result["timestamps"] + + # -- Before cost ----------------------------------------------------- + before_costs: dict[str, Any] | None = None + + # Try opower first + opower_acct = cache.get_opower_account() + if opower_acct is not None and ha_client is not None: + from span_panel_simulator.ha_api.client import HAClient + + if isinstance(ha_client, HAClient): + from datetime import datetime, timezone + from zoneinfo import ZoneInfo + + tz = ZoneInfo(tz_str) + start_dt = datetime.fromtimestamp(ts_list[0], tz=tz) + end_dt = datetime.fromtimestamp(ts_list[-1], tz=tz) + start_iso = start_dt.astimezone(timezone.utc).isoformat() + end_iso = end_dt.astimezone(timezone.utc).isoformat() + + try: + opower_result = await async_get_opower_cost( + ha_client, + opower_acct["cost_entity_id"], + start_iso, + end_iso, + ) + if opower_result is not None: + # Calculate expected days in horizon for coverage note + horizon_days = (end_dt - start_dt).days or 1 + before_costs = { + "source": "opower", + "net_cost": round(opower_result.total_cost, 2), + "days_with_data": opower_result.days_with_data, + "horizon_days": horizon_days, + } + except Exception: + _LOGGER.exception("Failed to fetch opower cost") + + # Fall back to URDB + if before_costs is None: + current_label = cache.get_current_rate_label() + if current_label is not None: + current_entry = cache.get_cached_rate(current_label) + if current_entry is not None: + costs = compute_costs(ts_list, result["site_power"], current_entry.record, tz_str) + before_costs = { + "source": "urdb", + "import_cost": round(costs.import_cost, 2), + "export_credit": round(costs.export_credit, 2), + "fixed_charges": round(costs.fixed_charges, 2), + "net_cost": round(costs.net_cost, 2), + } + + if before_costs is not None: + result["before_costs"] = before_costs + + # -- After cost (always URDB) ---------------------------------------- + current_label = cache.get_current_rate_label() + if current_label is None: + return + current_entry = cache.get_cached_rate(current_label) + if current_entry is None: + return + + after_record = current_entry.record + if proposed_rate_label: + proposed_entry = cache.get_cached_rate(proposed_rate_label) + if proposed_entry is not None: + after_record = proposed_entry.record + after_costs_result = compute_costs(ts_list, result["grid_power"], after_record, tz_str) + result["after_costs"] = { + "source": "urdb", + "import_cost": round(after_costs_result.import_cost, 2), + "export_credit": round(after_costs_result.export_credit, 2), + "fixed_charges": round(after_costs_result.fixed_charges, 2), + "net_cost": round(after_costs_result.net_cost, 2), + } +``` + +- [ ] **Step 3: Run full test suite** + +Run: `uv run pytest tests/ -x -q` +Expected: All tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/span_panel_simulator/dashboard/routes.py +git commit -m "Use opower actual billed cost for Before when available" +``` + +--- + +## Phase 4: Modeling View UI + +### Task 5: Conditional Billing Data Section and Cost Source Handling + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/modeling_view.html` + +- [ ] **Step 1: Add Billing Data HTML section** + +Insert before the existing `` div (before `
+ + + + +``` + +- [ ] **Step 2: Add opower JavaScript** + +Add after the existing rate plan state variables: + +```javascript + // -- Opower state -- + var opowerSectionEl = document.getElementById('modeling-opower-section'); + var opowerDisplayEl = document.getElementById('opower-account-display'); + var opowerChangeBtn = document.getElementById('btn-opower-change'); + var opowerPickerOverlay = document.getElementById('opower-picker-overlay'); + var opowerAccounts = []; + + function loadOpowerAccounts() { + fetch('rates/opower-accounts') + .then(function(r) { return r.json(); }) + .then(function(accounts) { + opowerAccounts = accounts; + if (accounts.length === 0) { + opowerSectionEl.style.display = 'none'; + return; + } + opowerSectionEl.style.display = ''; + // Check if account already saved + fetch('rates/opower-account') + .then(function(r) { return r.json(); }) + .then(function(saved) { + if (saved.configured) { + showOpowerAccount(saved); + } else if (accounts.length === 1) { + // Auto-select single account + selectOpowerAccount(accounts[0]); + } else { + openOpowerPicker(); + } + }); + }) + .catch(function() { + opowerSectionEl.style.display = 'none'; + }); + } + + function showOpowerAccount(account) { + opowerDisplayEl.textContent = account.utility_name + ' \u2014 ELEC ' + account.account_number; + opowerChangeBtn.style.display = opowerAccounts.length > 1 ? '' : 'none'; + } + + function selectOpowerAccount(account) { + fetch('rates/opower-account', { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(account), + }).then(function() { + showOpowerAccount(account); + closeOpowerPicker(); + fetchModelingData(horizonSelect.value); + }); + } + + function openOpowerPicker() { + var list = document.getElementById('opower-picker-list'); + list.innerHTML = ''; + for (var i = 0; i < opowerAccounts.length; i++) { + var acct = opowerAccounts[i]; + var btn = document.createElement('button'); + btn.className = 'btn btn-xs'; + btn.style.cssText = 'display:block; width:100%; text-align:left; margin-bottom:0.5rem; padding:0.5rem'; + btn.textContent = acct.utility_name + ' \u2014 ELEC ' + acct.account_number; + btn.dataset.index = String(i); + btn.addEventListener('click', function() { + selectOpowerAccount(opowerAccounts[parseInt(this.dataset.index)]); + }); + list.appendChild(btn); + } + opowerPickerOverlay.style.display = 'flex'; + } + + function closeOpowerPicker() { + opowerPickerOverlay.style.display = 'none'; + } + + opowerChangeBtn.addEventListener('click', openOpowerPicker); + document.getElementById('btn-opower-picker-cancel').addEventListener('click', closeOpowerPicker); +``` + +- [ ] **Step 3: Update enterModelingMode to load opower** + +In the `enterModelingMode` function, add before `loadCurrentRate()`: + +```javascript + loadOpowerAccounts(); +``` + +In `exitModelingMode`, add: + +```javascript + opowerSectionEl.style.display = 'none'; +``` + +- [ ] **Step 4: Update populateCostCell to handle opower source** + +Replace the existing `populateCostCell` function: + +```javascript + function populateCostCell(el, costs) { + if (!costs) { el.textContent = ''; return; } + if (costs.source === 'opower') { + var text = 'Cost: ' + formatDollar(costs.net_cost) + ' (billed)'; + if (costs.days_with_data && costs.horizon_days && costs.days_with_data < costs.horizon_days * 0.9) { + var months = Math.round(costs.days_with_data / 30); + var totalMonths = Math.round(costs.horizon_days / 30); + text += ' (' + months + ' of ' + totalMonths + ' months)'; + } + el.textContent = text; + } else { + el.textContent = formatDollar(costs.import_cost) + ' imp, ' + formatDollar(costs.export_credit) + + ' exp \u2014 Net: ' + formatDollar(costs.net_cost); + } + } +``` + +- [ ] **Step 5: Update populateCostDiffCell to use net_cost from either source** + +The existing `populateCostDiffCell` already uses `beforeCosts.net_cost` and `afterCosts.net_cost` — both opower and URDB include `net_cost`, so no change needed. Verify this is the case. + +- [ ] **Step 6: Commit** + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/modeling_view.html +git commit -m "Add conditional opower billing data section to modeling view" +``` + +--- + +## Phase 5: Utility Pre-filter and Final Wiring + +### Task 6: Pre-filter URDB Utility from Opower + +**Files:** +- Modify: `src/span_panel_simulator/dashboard/templates/partials/modeling_view.html` + +- [ ] **Step 1: Update loadUtilities to accept a default utility name** + +Modify the `loadUtilities` function to accept an optional utility name parameter: + +```javascript + function loadUtilities(defaultUtility) { + var latEl = document.querySelector('[name="latitude"]'); + var lonEl = document.querySelector('[name="longitude"]'); + var lat = (latEl && parseFloat(latEl.value)) || 37.7; + var lon = (lonEl && parseFloat(lonEl.value)) || -122.4; + utilitySelect.innerHTML = ''; + planSelect.innerHTML = ''; + planSelect.disabled = true; + useRateBtn.disabled = true; + + fetch('rates/utilities?lat=' + lat + '&lon=' + lon) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.error) { + utilitySelect.innerHTML = ''; + return; + } + utilitySelect.innerHTML = ''; + var matchIndex = -1; + for (var i = 0; i < data.length; i++) { + var opt = document.createElement('option'); + opt.value = data[i].utility_name; + opt.textContent = data[i].utility_name; + utilitySelect.appendChild(opt); + if (defaultUtility && data[i].utility_name.indexOf(defaultUtility) !== -1) { + matchIndex = i + 1; // +1 for the placeholder option + } + } + if (matchIndex > 0) { + utilitySelect.selectedIndex = matchIndex; + loadRatePlans(utilitySelect.value); + } + }) + .catch(function() { + utilitySelect.innerHTML = ''; + }); + } +``` + +- [ ] **Step 2: Pass opower utility name when opening rate dialog** + +Update the `openRateDialog` function to use the opower utility name: + +```javascript + function openRateDialog(target) { + rateDialogTarget = target; + dialogError.style.display = 'none'; + dialogOverlay.style.display = 'flex'; + loadOpenEIConfig(); + // Pre-filter by opower utility if available + var opowerAcct = null; + var opowerDisplay = opowerDisplayEl.textContent; + if (opowerDisplay && opowerSectionEl.style.display !== 'none') { + // Extract utility name portion (before the em-dash) + var parts = opowerDisplay.split('\u2014'); + if (parts.length > 0) opowerAcct = parts[0].trim(); + } + loadUtilities(opowerAcct || null); + } +``` + +- [ ] **Step 3: Update existing callers of loadUtilities** + +The only other caller of `loadUtilities` is in the `btn-rate-save-config` event listener. Update it: + +```javascript + document.getElementById('btn-rate-save-config').addEventListener('click', function() { + var url = document.getElementById('rate-api-url').value.trim(); + var key = document.getElementById('rate-api-key').value.trim(); + fetch('rates/openei-config', { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({api_url: url, api_key: key}), + }).then(function() { loadUtilities(null); }); + }); +``` + +- [ ] **Step 4: Run full test suite** + +Run: `uv run pytest tests/ -x -q` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/span_panel_simulator/dashboard/templates/partials/modeling_view.html +git commit -m "Pre-filter URDB utility list from opower account name" +``` + +--- + +### Task 7: Update ha_api Package Exports + +**Files:** +- Modify: `src/span_panel_simulator/ha_api/__init__.py` + +- [ ] **Step 1: Add opower exports** + +```python +"""Home Assistant API client — dual-mode access for add-on and local development.""" + +from __future__ import annotations + +from span_panel_simulator.ha_api.client import HAClient +from span_panel_simulator.ha_api.manifest import ( + CircuitManifestEntry, + PanelManifest, + fetch_all_manifests, +) +from span_panel_simulator.ha_api.opower import ( + OpowerAccount, + OpowerCostResult, + async_discover_opower, + async_get_opower_cost, +) + +__all__ = [ + "CircuitManifestEntry", + "HAClient", + "OpowerAccount", + "OpowerCostResult", + "PanelManifest", + "async_discover_opower", + "async_get_opower_cost", + "fetch_all_manifests", +] +``` + +- [ ] **Step 2: Run full test suite and type checker** + +Run: `uv run pytest tests/ -x -q` +Run: `uv run mypy src/span_panel_simulator/ha_api/` + +- [ ] **Step 3: Commit** + +```bash +git add src/span_panel_simulator/ha_api/__init__.py +git commit -m "Export opower types and functions from ha_api package" +``` + +--- + +## Summary + +| Phase | Tasks | What it delivers | +|-------|-------|-----------------| +| 1. Discovery & Fetch | 1 | Opower account discovery + daily cost sum from HA | +| 2. Cache Extension | 2 | Opower account persistence in rates_cache.yaml | +| 3. API & Modeling | 3-4 | Endpoints + _attach_costs with opower priority | +| 4. UI | 5 | Conditional billing data section + source-aware cost display | +| 5. Polish | 6-7 | URDB utility pre-filter + package exports | diff --git a/docs/superpowers/specs/2026-03-28-opower-before-cost-design.md b/docs/superpowers/specs/2026-03-28-opower-before-cost-design.md new file mode 100644 index 0000000..ab04493 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-opower-before-cost-design.md @@ -0,0 +1,258 @@ +# Opower Before Cost Integration Design + +Use actual billed cost from the utility (via HA's Opower integration) for the Before cost in the modeling view, replacing the URDB-calculated estimate when available. + +--- + +## Scope + +**In scope:** +- Discover Opower ELEC accounts via HA config entries and device/entity registry +- Fetch daily cost statistics from HA recorder for a given date range +- Use opower billed cost as Before cost when available +- Fall back to URDB calculation when opower is not available +- Opower account selection UI in modeling view (conditional on HA + opower) +- Pre-filter URDB utility list using opower utility name + +**Out of scope:** +- Rate plan inference from opower cost/usage data (future) +- Standalone opower usage (no HA = no opower) +- Gas account support (ELEC only) +- Opower data caching beyond the modeling session +- Export/import cost split from opower (only net cost available) + +--- + +## Before Cost Source Priority + +``` +1. Opower daily cost sum (HA connected + opower account selected + data available) +2. URDB calculation against recorder power arrays (fallback) +``` + +After cost is always URDB (proposed rate if set, otherwise current rate). This is unchanged. + +--- + +## Architecture + +### HA API Layer + +New file `src/span_panel_simulator/ha_api/opower.py` — composes existing `HAClient` methods, does not modify the client itself. + +**Discovery:** + +```python +@dataclass(frozen=True) +class OpowerAccount: + """An opower ELEC account discovered from HA.""" + device_id: str + utility_name: str + account_number: str + cost_entity_id: str + usage_entity_id: str + +async def async_discover_opower(client: HAClient) -> list[OpowerAccount]: + """Find opower ELEC accounts via HA config entries and device registry.""" +``` + +Logic: +1. Query config entries via WebSocket `config_entries/get` +2. Filter for entries with `domain: "opower"` +3. For each opower config entry, find devices via `config/device_registry/list` where `config_entries` contains the entry ID +4. Filter devices to ELEC accounts (device name or identifiers contain "ELEC") +5. For each ELEC device, find entities via `config/entity_registry/list` +6. Identify the cost entity (device_class `monetary` or entity_id containing `cost_to_date`) and usage entity +7. Return `OpowerAccount` for each + +**Statistics fetch:** + +```python +async def async_get_opower_cost( + client: HAClient, + cost_entity_id: str, + start_time: str, + end_time: str, +) -> float | None: + """Sum daily billed cost from opower statistics over a date range. + + Returns the total cost in dollars, or None if no data is available. + """ +``` + +Logic: +1. Call `client.async_get_statistics([cost_entity_id], period="day", start_time=..., end_time=...)` +2. The opower cost entity uses `state_class: total` — the recorder stores cumulative values. Each daily statistic entry has a `change` field representing the cost delta for that day. +3. Sum the `change` values across the date range to get total cost +4. Return the total, or None if the response is empty or the entity has no data + +### Rate Cache Extension + +Two new methods on `RateCache`: + +```python +def get_opower_account(self) -> dict[str, str] | None: + """Return the saved opower account selection, or None.""" + +def set_opower_account( + self, device_id: str, utility_name: str, + account_number: str, cost_entity_id: str, usage_entity_id: str, +) -> None: + """Save the opower account selection.""" +``` + +Stored in `rates_cache.yaml`: + +```yaml +opower: + device_id: "abc123def456" + utility_name: "Pacific Gas and Electric Company (PG&E)" + account_number: "3021618479" + cost_entity_id: "sensor.opower_pge_elec_cost_to_date" + usage_entity_id: "sensor.opower_pge_elec_usage_to_date" +``` + +### Modeling Route Changes + +**New endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/rates/opower-accounts` | Discover opower ELEC accounts from HA | +| PUT | `/rates/opower-account` | Save selected opower account | +| GET | `/rates/opower-account` | Get saved opower account | + +**Modified `_attach_costs`:** + +Before cost source selection: +1. Check if opower account is saved in rate cache +2. If saved AND HA client is available, fetch daily cost via `async_get_opower_cost` for the horizon date range +3. If opower returns a cost, use it: + ```json + "before_costs": { + "source": "opower", + "net_cost": 342.00 + } + ``` +4. If opower is not available or returns None, fall back to URDB: + ```json + "before_costs": { + "source": "urdb", + "import_cost": 185.10, + "export_credit": 42.30, + "fixed_charges": 30.00, + "net_cost": 142.80 + } + ``` + +After cost is always URDB (unchanged): +```json +"after_costs": { + "source": "urdb", + "import_cost": 120.50, + "export_credit": 55.10, + "fixed_charges": 30.00, + "net_cost": 65.40 +} +``` + +**Opower cost is fetched once** when `_attach_costs` is first called in a modeling session. The result is held on the request or passed through — not re-fetched on every horizon change. (The HA WebSocket call is local, but the data only updates every 48 hours from the utility, so repeated fetching is wasteful.) + +--- + +## Modeling View UI + +### Conditional Billing Data Section + +Only appears when HA is connected and opower ELEC account(s) are discovered. + +**Layout when opower is available:** + +``` +Billing Data (Opower) +PG&E -- ELEC account 3021618479 [Change] (only if multiple accounts) +Before cost: actual billed amount + +Current Rate Proposed Rate +PG&E -- E-TOU-C (2024) [Change] [Refresh] Using current rate [Set Proposed Rate] +After cost: calculated from selected rate +``` + +**Layout when opower is NOT available:** + +Same as current — no Billing Data section, URDB for both Before and After. + +### On Modeling Mode Entry + +1. Fetch `GET /rates/opower-accounts` +2. If accounts found and none saved → auto-select if single account, show picker if multiple +3. If account saved → show it in the Billing Data section +4. If no accounts found → hide Billing Data section entirely + +### Before Summary Card Display + +**With opower:** +``` +Full Horizon Imported 2.8k kWh / Exported 636 kWh Cost: $342.00 +Visible Range Imported 1.2k kWh / Exported 280 kWh -- +``` + +**With URDB (no opower):** +``` +Full Horizon Imported 2.8k kWh ($185.10) / Exported 636 kWh ($42.30) Net: $142.80 +Visible Range Imported 1.2k kWh / Exported 280 kWh -- +``` + +Visible range never shows cost for Before (daily cost data cannot be meaningfully sliced to a sub-horizon range). + +### URDB Utility Pre-filter + +When the opower account is set, its `utility_name` is used to pre-populate the utility dropdown in the OpenEI dialog. The user can still change it, but the default match saves a step. + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| HA not connected (standalone) | Opower section hidden, URDB for both | +| HA connected but opower not installed | Opower section hidden, URDB for both | +| Opower installed but no ELEC account | Opower section hidden | +| Opower ELEC found but no statistics yet | Show section, Before cost: "Awaiting billing data", fall back to URDB | +| Statistics don't cover full horizon | Use available data, note: "Cost: $342.00 (3 of 6 months)" | +| Saved device_id no longer in HA | Clear saved selection, re-prompt discovery | +| Multiple ELEC accounts | Show picker | +| Single ELEC account | Auto-select, no picker | + +--- + +## Testing Strategy + +**Unit tests (`tests/test_ha_api/test_opower.py`):** +- `async_discover_opower`: mocked config entries + device/entity registry — finds ELEC, ignores GAS, handles no opower +- `async_get_opower_cost`: mocked statistics — daily sum, partial data, empty data returns None + +**Unit tests (`tests/test_rates/test_cache.py`):** +- `get_opower_account` / `set_opower_account` — persistence and retrieval + +**Route integration:** +- `_attach_costs` with opower source — `before_costs` has `source: "opower"` and `net_cost` only +- `_attach_costs` without opower — existing URDB behavior unchanged +- `GET /rates/opower-accounts` — returns accounts or empty list + +--- + +## Future Extensions + +- **Rate plan inference** -- compare opower hourly cost/usage against URDB rate schedules to suggest which plan the user is on +- **Gas account support** -- extend to GAS opower accounts for dual-fuel modeling +- **Standalone opower** -- use opower library directly (with utility credentials) when HA is not available + +--- + +## References + +- [2026-03-28-tou-rate-integration-design.md](2026-03-28-tou-rate-integration-design.md) -- Parent rate integration spec +- [Home Assistant Opower Integration](https://www.home-assistant.io/integrations/opower/) +- [tronikos/opower](https://github.com/tronikos/opower) -- Standalone Python opower library +- HA WebSocket API: `config_entries/get`, `config/device_registry/list`, `config/entity_registry/list`, `recorder/statistics_during_period` diff --git a/docs/superpowers/specs/2026-03-28-tou-rate-integration-design.md b/docs/superpowers/specs/2026-03-28-tou-rate-integration-design.md index 46a471d..225d735 100644 --- a/docs/superpowers/specs/2026-03-28-tou-rate-integration-design.md +++ b/docs/superpowers/specs/2026-03-28-tou-rate-integration-design.md @@ -376,6 +376,7 @@ When no rate is configured, the existing energy-only display remains unchanged. These are explicitly deferred and not part of this implementation: +- **Opower Before cost** -- when HA is connected and the opower integration is configured, use actual billed cost from the utility for Before instead of calculating it from URDB. Opower historical cost/usage data is available via HA's `recorder/statistics_during_period` WebSocket API on `sensor.opower_*_elec_cost_to_date` entities. No additional credentials needed -- the simulator reads what opower already collected. URDB remains required for After cost (modeled scenarios). Opower data can also help suggest the correct URDB rate plan by comparing calculated vs actual costs, though this is unreliable if the user recently changed plans. - **Tiered rate calculation** -- use consumption brackets instead of always tier 1 - **Manual rate entry** -- fallback when URDB lacks the user's tariff - **Demand charge time-series** -- calculate demand charges based on peak kW per billing period diff --git a/pyproject.toml b/pyproject.toml index 1433a9d..ba5ca03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "span-panel-simulator" -version = "1.0.8" +version = "1.0.9" description = "Standalone eBus simulator for SPAN panels" requires-python = ">=3.12" dependencies = [ diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile index 5d8398d..b1279b4 100644 --- a/span_panel_simulator/Dockerfile +++ b/span_panel_simulator/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080 LABEL io.hass.name="SPAN Panel Simulator" \ io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \ io.hass.type="addon" \ - io.hass.version="1.0.8" \ + io.hass.version="1.0.9" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index fe9af5e..1456a4f 100644 --- a/span_panel_simulator/config.yaml +++ b/span_panel_simulator/config.yaml @@ -1,6 +1,6 @@ name: "SPAN Panel Simulator" description: "Simulates a SPAN electrical panel for testing and upgrade modeling" -version: "1.0.8" +version: "1.0.9" slug: "span_panel_simulator" url: "https://github.com/SpanPanel/simulator" image: "ghcr.io/spanpanel/simulator/{arch}" diff --git a/src/span_panel_simulator/__init__.py b/src/span_panel_simulator/__init__.py index 26f9804..fdf3591 100644 --- a/src/span_panel_simulator/__init__.py +++ b/src/span_panel_simulator/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "1.0.8" +__version__ = "1.0.9" diff --git a/src/span_panel_simulator/dashboard/__init__.py b/src/span_panel_simulator/dashboard/__init__.py index 392f7b1..913c1dc 100644 --- a/src/span_panel_simulator/dashboard/__init__.py +++ b/src/span_panel_simulator/dashboard/__init__.py @@ -19,10 +19,12 @@ APP_KEY_DASHBOARD_CONTEXT, APP_KEY_PENDING_CLONES, APP_KEY_PRESET_REGISTRY, + APP_KEY_RATE_CACHE, APP_KEY_STORE, ) from span_panel_simulator.dashboard.presets import init_presets from span_panel_simulator.dashboard.routes import setup_routes +from span_panel_simulator.rates.cache import RateCache __all__ = ["DashboardContext", "create_dashboard_app"] @@ -49,6 +51,7 @@ def create_dashboard_app(context: DashboardContext) -> web.Application: app[APP_KEY_DASHBOARD_CONTEXT] = context app[APP_KEY_PRESET_REGISTRY] = init_presets(context.config_dir) app[APP_KEY_PENDING_CLONES] = {} + app[APP_KEY_RATE_CACHE] = RateCache(context.config_dir / "rates" / "rates_cache.yaml") template_dir = Path(__file__).parent / "templates" env = aiohttp_jinja2.setup( diff --git a/src/span_panel_simulator/dashboard/keys.py b/src/span_panel_simulator/dashboard/keys.py index 1c6eb5e..45dce91 100644 --- a/src/span_panel_simulator/dashboard/keys.py +++ b/src/span_panel_simulator/dashboard/keys.py @@ -11,6 +11,7 @@ from span_panel_simulator.dashboard.config_store import ConfigStore from span_panel_simulator.dashboard.context import DashboardContext from span_panel_simulator.dashboard.presets import PresetRegistry +from span_panel_simulator.rates.cache import RateCache APP_KEY_STORE = web.AppKey("store", ConfigStore) APP_KEY_DASHBOARD_CONTEXT = web.AppKey("dashboard_context", DashboardContext) @@ -18,3 +19,4 @@ APP_KEY_PENDING_CLONES: web.AppKey[dict[str, dict[str, object]]] = web.AppKey( "pending_clones", dict ) +APP_KEY_RATE_CACHE = web.AppKey("rate_cache", RateCache) diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index ea5848d..a990301 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -21,10 +21,13 @@ from span_panel_simulator.dashboard.context import DashboardContext +from datetime import UTC + from span_panel_simulator.dashboard.keys import ( APP_KEY_DASHBOARD_CONTEXT, APP_KEY_PENDING_CLONES, APP_KEY_PRESET_REGISTRY, + APP_KEY_RATE_CACHE, APP_KEY_STORE, ) from span_panel_simulator.dashboard.modeling_config import resolve_modeling_config_filename @@ -33,11 +36,23 @@ is_random_days_preset, match_battery_preset, ) +from span_panel_simulator.ha_api.opower import ( + async_discover_opower, + async_get_opower_cost, +) +from span_panel_simulator.rates.cost_engine import compute_costs +from span_panel_simulator.rates.openei import ( + OpenEIError, + fetch_rate_detail, + fetch_rate_plans, + fetch_utilities, +) from span_panel_simulator.solar import compute_solar_curve from span_panel_simulator.weather import fetch_historical_weather, get_cached_weather if TYPE_CHECKING: from span_panel_simulator.dashboard.config_store import ConfigStore + from span_panel_simulator.rates.cache import RateCache _LOGGER = logging.getLogger(__name__) @@ -77,6 +92,10 @@ def _store(request: web.Request) -> ConfigStore: return request.app[APP_KEY_STORE] +def _rate_cache(request: web.Request) -> RateCache: + return request.app[APP_KEY_RATE_CACHE] + + def _ctx(request: web.Request) -> DashboardContext: return request.app[APP_KEY_DASHBOARD_CONTEXT] @@ -250,6 +269,217 @@ def _battery_profile_context(request: web.Request, entity_id: str) -> dict[str, } +async def handle_get_openei_config(request: web.Request) -> web.Response: + """GET /rates/openei-config""" + config = _rate_cache(request).get_openei_config() + return web.json_response({"api_url": config.api_url, "api_key": config.api_key}) + + +async def handle_put_openei_config(request: web.Request) -> web.Response: + """PUT /rates/openei-config""" + body = await request.json() + api_url = body.get("api_url", "").strip() + api_key = body.get("api_key", "").strip() + if not api_url or not api_key: + return web.json_response({"error": "api_url and api_key are required"}, status=400) + _rate_cache(request).set_openei_config(api_url, api_key) + return web.json_response({"ok": True}) + + +async def handle_get_utilities(request: web.Request) -> web.Response: + """GET /rates/utilities?lat=&lon=""" + lat = request.query.get("lat") + lon = request.query.get("lon") + if lat is None or lon is None: + return web.json_response({"error": "lat and lon are required"}, status=400) + config = _rate_cache(request).get_openei_config() + if not config.api_key: + return web.json_response({"error": "OpenEI API key not configured"}, status=400) + try: + results = await fetch_utilities(float(lat), float(lon), config.api_url, config.api_key) + except OpenEIError as e: + return web.json_response({"error": str(e)}, status=502) + return web.json_response( + [{"utility_name": u.utility_name, "eia_id": u.eia_id} for u in results] + ) + + +async def handle_get_rate_plans(request: web.Request) -> web.Response: + """GET /rates/plans?utility=§or=""" + utility = request.query.get("utility") + if not utility: + return web.json_response({"error": "utility is required"}, status=400) + sector = request.query.get("sector", "Residential") + config = _rate_cache(request).get_openei_config() + if not config.api_key: + return web.json_response({"error": "OpenEI API key not configured"}, status=400) + try: + plans = await fetch_rate_plans(utility, config.api_url, config.api_key, sector) + except OpenEIError as e: + return web.json_response({"error": str(e)}, status=502) + return web.json_response( + [ + { + "label": p.label, + "name": p.name, + "startdate": p.startdate, + "enddate": p.enddate, + "description": p.description, + } + for p in plans + ] + ) + + +async def handle_fetch_rate(request: web.Request) -> web.Response: + """POST /rates/fetch {label}""" + body = await request.json() + label = body.get("label", "").strip() + if not label: + return web.json_response({"error": "label is required"}, status=400) + config = _rate_cache(request).get_openei_config() + if not config.api_key: + return web.json_response({"error": "OpenEI API key not configured"}, status=400) + try: + record = await fetch_rate_detail(label, config.api_url, config.api_key) + except OpenEIError as e: + return web.json_response({"error": str(e)}, status=502) + cache = _rate_cache(request) + cache.cache_rate(label, record) + return web.json_response( + { + "label": label, + "utility": record.get("utility", ""), + "name": record.get("name", ""), + } + ) + + +async def handle_refresh_rate(request: web.Request) -> web.Response: + """POST /rates/refresh {label}""" + body = await request.json() + label = body.get("label", "").strip() + if not label: + return web.json_response({"error": "label is required"}, status=400) + config = _rate_cache(request).get_openei_config() + if not config.api_key: + return web.json_response({"error": "OpenEI API key not configured"}, status=400) + try: + record = await fetch_rate_detail(label, config.api_url, config.api_key) + except OpenEIError as e: + return web.json_response({"error": str(e)}, status=502) + _rate_cache(request).cache_rate(label, record) + return web.json_response({"ok": True, "label": label}) + + +async def handle_get_rates_cache(request: web.Request) -> web.Response: + """GET /rates/cache""" + return web.json_response(_rate_cache(request).list_cached_rates()) + + +async def handle_get_current_rate(request: web.Request) -> web.Response: + """GET /rates/current""" + cache = _rate_cache(request) + label = cache.get_current_rate_label() + if label is None: + return web.json_response({"label": None}) + entry = cache.get_cached_rate(label) + if entry is None: + return web.json_response({"label": label, "error": "cached record missing"}) + return web.json_response( + { + "label": label, + "utility": entry.record.get("utility", ""), + "name": entry.record.get("name", ""), + "retrieved_at": entry.retrieved_at, + } + ) + + +async def handle_put_current_rate(request: web.Request) -> web.Response: + """PUT /rates/current {label}""" + body = await request.json() + label = body.get("label", "").strip() + if not label: + return web.json_response({"error": "label is required"}, status=400) + _rate_cache(request).set_current_rate_label(label) + return web.json_response({"ok": True}) + + +async def handle_get_rate_detail(request: web.Request) -> web.Response: + """GET /rates/detail/{label}""" + label = request.match_info["label"] + entry = _rate_cache(request).get_cached_rate(label) + if entry is None: + return web.json_response({"error": "not found"}, status=404) + return web.json_response(entry.record) + + +async def handle_get_rate_attribution(request: web.Request) -> web.Response: + """GET /rates/attribution/{label}""" + label = request.match_info["label"] + entry = _rate_cache(request).get_cached_rate(label) + if entry is None: + return web.json_response({"error": "not found"}, status=404) + return web.json_response( + { + "provider": entry.attribution.provider, + "url": entry.attribution.url, + "license": entry.attribution.license, + "api_version": entry.attribution.api_version, + "retrieved_at": entry.retrieved_at, + } + ) + + +async def handle_get_opower_accounts(request: web.Request) -> web.Response: + """GET /rates/opower-accounts — discover opower ELEC accounts from HA.""" + ctx = _ctx(request) + if ctx.ha_client is None: + return web.json_response([]) + try: + accounts = await async_discover_opower(ctx.ha_client) + except Exception: + _LOGGER.exception("Failed to discover opower accounts") + return web.json_response([]) + return web.json_response( + [ + { + "device_id": a.device_id, + "utility_name": a.utility_name, + "account_number": a.account_number, + "cost_entity_id": a.cost_entity_id, + "usage_entity_id": a.usage_entity_id, + } + for a in accounts + ] + ) + + +async def handle_get_opower_account(request: web.Request) -> web.Response: + """GET /rates/opower-account — get saved opower account.""" + account = _rate_cache(request).get_opower_account() + if account is None: + return web.json_response({"configured": False}) + return web.json_response({**account, "configured": True}) + + +async def handle_put_opower_account(request: web.Request) -> web.Response: + """PUT /rates/opower-account — save selected opower account.""" + body = await request.json() + device_id = body.get("device_id", "").strip() + if not device_id: + return web.json_response({"error": "device_id is required"}, status=400) + _rate_cache(request).set_opower_account( + device_id=device_id, + utility_name=body.get("utility_name", ""), + account_number=body.get("account_number", ""), + cost_entity_id=body.get("cost_entity_id", ""), + usage_entity_id=body.get("usage_entity_id", ""), + ) + return web.json_response({"ok": True}) + + def setup_routes(app: web.Application) -> None: """Register all dashboard routes.""" # Full page @@ -340,6 +570,24 @@ def setup_routes(app: web.Application) -> None: # Panel discovery (mDNS + HA manifest) app.router.add_get("/discovered-panels", handle_discovered_panels) + # Rate plan management + app.router.add_get("/rates/openei-config", handle_get_openei_config) + app.router.add_put("/rates/openei-config", handle_put_openei_config) + app.router.add_get("/rates/utilities", handle_get_utilities) + app.router.add_get("/rates/plans", handle_get_rate_plans) + app.router.add_post("/rates/fetch", handle_fetch_rate) + app.router.add_post("/rates/refresh", handle_refresh_rate) + app.router.add_get("/rates/cache", handle_get_rates_cache) + app.router.add_get("/rates/current", handle_get_current_rate) + app.router.add_put("/rates/current", handle_put_current_rate) + app.router.add_get("/rates/detail/{label}", handle_get_rate_detail) + app.router.add_get("/rates/attribution/{label}", handle_get_rate_attribution) + + # Opower account management + app.router.add_get("/rates/opower-accounts", handle_get_opower_accounts) + app.router.add_get("/rates/opower-account", handle_get_opower_account) + app.router.add_put("/rates/opower-account", handle_put_opower_account) + # -- Full page -- @@ -1010,9 +1258,105 @@ async def handle_modeling_data(request: web.Request) -> web.Response: return web.json_response({"error": "No running simulation"}, status=503) if "error" in result: return web.json_response(result, status=400) + + # Attach cost data if rate cache is available + cache = _rate_cache(request) + proposed_label = request.query.get("proposed_rate_label") + await _attach_costs(result, cache, proposed_label, ctx.ha_client) + return web.json_response(result) +async def _attach_costs( + result: dict[str, Any], + cache: RateCache, + proposed_rate_label: str | None, + ha_client: Any, +) -> None: + """Add before_costs and after_costs to a modeling result dict. + + Before cost priority: + 1. Opower actual billed cost (if HA + opower account configured) + 2. URDB calculation against recorder power arrays + After cost: always URDB. + """ + tz_str: str = result["time_zone"] + ts_list: list[int] = result["timestamps"] + + # -- Before cost ----------------------------------------------------- + before_costs: dict[str, Any] | None = None + + # Try opower first + opower_acct = cache.get_opower_account() + if opower_acct is not None and ha_client is not None: + from datetime import datetime + from zoneinfo import ZoneInfo + + tz = ZoneInfo(tz_str) + start_dt = datetime.fromtimestamp(ts_list[0], tz=tz) + end_dt = datetime.fromtimestamp(ts_list[-1], tz=tz) + start_iso = start_dt.astimezone(UTC).isoformat() + end_iso = end_dt.astimezone(UTC).isoformat() + + try: + opower_result = await async_get_opower_cost( + ha_client, + opower_acct["cost_entity_id"], + start_iso, + end_iso, + ) + if opower_result is not None: + horizon_days = (end_dt - start_dt).days or 1 + before_costs = { + "source": "opower", + "net_cost": round(opower_result.total_cost, 2), + "days_with_data": opower_result.days_with_data, + "horizon_days": horizon_days, + } + except Exception: + _LOGGER.exception("Failed to fetch opower cost") + + # Fall back to URDB + if before_costs is None: + current_label = cache.get_current_rate_label() + if current_label is not None: + current_entry = cache.get_cached_rate(current_label) + if current_entry is not None: + costs = compute_costs(ts_list, result["site_power"], current_entry.record, tz_str) + before_costs = { + "source": "urdb", + "import_cost": round(costs.import_cost, 2), + "export_credit": round(costs.export_credit, 2), + "fixed_charges": round(costs.fixed_charges, 2), + "net_cost": round(costs.net_cost, 2), + } + + if before_costs is not None: + result["before_costs"] = before_costs + + # -- After cost (always URDB) ---------------------------------------- + current_label = cache.get_current_rate_label() + if current_label is None: + return + current_entry = cache.get_cached_rate(current_label) + if current_entry is None: + return + + after_record = current_entry.record + if proposed_rate_label: + proposed_entry = cache.get_cached_rate(proposed_rate_label) + if proposed_entry is not None: + after_record = proposed_entry.record + after_costs_result = compute_costs(ts_list, result["grid_power"], after_record, tz_str) + result["after_costs"] = { + "source": "urdb", + "import_cost": round(after_costs_result.import_cost, 2), + "export_credit": round(after_costs_result.export_credit, 2), + "fixed_charges": round(after_costs_result.fixed_charges, 2), + "net_cost": round(after_costs_result.net_cost, 2), + } + + # -- File operations -- diff --git a/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html b/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html index 1a1acb6..6294473 100644 --- a/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html +++ b/src/span_panel_simulator/dashboard/templates/partials/modeling_view.html @@ -33,6 +33,115 @@

Modeling —

Loading modeling data...
+ + + + + + + + + + + + + + +