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 `
+
+
+
Billing Data (Opower)
+
+
Change
+
+
+
+
+
+
+
Select Electric Account
+
+
+ Cancel
+
+
+
+```
+
+- [ ] **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 = '
Loading... ';
+ planSelect.innerHTML = '
Select a utility first ';
+ 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 = '
Error: ' + data.error + ' ';
+ return;
+ }
+ utilitySelect.innerHTML = '
Select a utility... ';
+ 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 = '
Error loading utilities ';
+ });
+ }
+```
+
+- [ ] **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...
+
+
+
+
Billing Data (Opower)
+
+
Change
+
+
+
+
+
+
+
Select Electric Account
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+ Current Rate
+ ⓘ
+
+
+ No rate plan selected
+
+
+ Configure
+ Refresh
+
+
+
+
+
Proposed Rate
+
+ Using current rate for comparison
+
+
+ Set Proposed Rate
+ Clear
+
+
+
+
+
+
+
+
+
OpenEI Rate Plan
+
+
API Settings
+
+ API URL
+
+
+
+ API Key
+
+
+
+
+
+
Select Rate Plan
+
+ Utility
+
+ Loading utilities...
+
+
+
+ Rate Plan
+
+ Select a utility first
+
+
+
+
+
+ Cancel
+ Use This Rate
+
+
+
+
+
+
+
+
Rate Data Source
+
+
+ Close
+
+
+
+
@@ -47,16 +156,19 @@
Modeling —
Energy (kWh)
+ Cost
Full Horizon
+
Visible Range
+
@@ -77,6 +189,8 @@
Modeling —
Energy (kWh)
Difference
+
Cost
+
Savings
@@ -84,11 +198,15 @@ Modeling —
Full Horizon
+
+
Visible Range
+
+
@@ -129,6 +247,18 @@
Modeling —
/** YAML filename for the panel being modeled — must match /modeling-data ?config= */
var modelingConfigFile = '';
+ // -- Rate plan state --
+ var currentRateLabel = null;
+ var proposedRateLabel = null;
+ var rateDialogTarget = 'current';
+
+ // -- 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 = [];
+
var OVERLAY_COLORS = ['#3b82f6','#8b5cf6','#ec4899','#14b8a6','#f97316','#6366f1','#84cc16','#e879f9'];
// DOM refs
@@ -150,6 +280,20 @@
Modeling —
var diffHorizonEl = document.getElementById('modeling-diff-horizon');
var diffVisibleEl = document.getElementById('modeling-diff-visible');
+ // Rate plan DOM refs
+ var rateSectionEl = document.getElementById('modeling-rate-section');
+ var currentDisplayEl = document.getElementById('rate-current-display');
+ var proposedDisplayEl = document.getElementById('rate-proposed-display');
+ var attributionBtn = document.getElementById('btn-rate-attribution');
+ var refreshCurrentBtn = document.getElementById('btn-rate-current-refresh');
+ var clearProposedBtn = document.getElementById('btn-rate-proposed-clear');
+ var dialogOverlay = document.getElementById('rate-dialog-overlay');
+ var attrOverlay = document.getElementById('rate-attribution-overlay');
+ var utilitySelect = document.getElementById('rate-utility-select');
+ var planSelect = document.getElementById('rate-plan-select');
+ var dialogError = document.getElementById('rate-dialog-error');
+ var useRateBtn = document.getElementById('btn-rate-dialog-use');
+
// ---------------------------------------------------------------
// Entry / Exit
// ---------------------------------------------------------------
@@ -165,6 +309,9 @@
Modeling —
cb.checked = false;
cb.closest('.entity-row').classList.remove('overlay-active');
});
+ rateSectionEl.style.display = '';
+ loadOpowerAccounts();
+ loadCurrentRate();
fetchModelingData(horizonSelect.value);
}
@@ -175,6 +322,9 @@
Modeling —
if (afterChart) { afterChart.destroy(); afterChart = null; }
modelingData = null;
chartsWrap.style.display = 'none';
+ rateSectionEl.style.display = 'none';
+ opowerSectionEl.style.display = 'none';
+ proposedRateLabel = null;
}
// ---------------------------------------------------------------
@@ -185,6 +335,9 @@
Modeling —
if (modelingConfigFile) {
q += '&config=' + encodeURIComponent(modelingConfigFile);
}
+ if (proposedRateLabel) {
+ q += '&proposed_rate_label=' + encodeURIComponent(proposedRateLabel);
+ }
return q;
}
@@ -287,6 +440,18 @@
Modeling —
populateEnergyCell(afterHorizonEl, fullAfterEnergy);
populateDiffCell(diffHorizonEl, fullBeforeEnergy, fullAfterEnergy);
+ // Cost summaries — full horizon (from server response)
+ var hasCosts = !!d.before_costs;
+ showCostColumns(hasCosts);
+ if (hasCosts) {
+ populateCostCell(document.getElementById('modeling-before-cost-horizon'), d.before_costs);
+ populateCostCell(document.getElementById('modeling-after-cost-horizon'), d.after_costs);
+ populateCostDiffCell(document.getElementById('modeling-cost-diff-horizon'), d.before_costs, d.after_costs);
+ document.getElementById('modeling-before-cost-visible').textContent = '\u2014';
+ document.getElementById('modeling-after-cost-visible').textContent = '\u2014';
+ document.getElementById('modeling-cost-diff-visible').textContent = '\u2014';
+ }
+
// Slider labels
rangeStartEl.textContent = formatDate(ts[0], d.time_zone);
rangeEndEl.textContent = formatDate(ts[ts.length - 1], d.time_zone);
@@ -426,6 +591,42 @@
Modeling —
el.className = 'modeling-col-diff ' + (diffKwh > 0 ? 'diff-positive' : diffKwh < 0 ? 'diff-negative' : '');
}
+ function formatDollar(amount) {
+ return '$' + Math.abs(amount).toFixed(2);
+ }
+
+ 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);
+ }
+ }
+
+ function populateCostDiffCell(el, beforeCosts, afterCosts) {
+ if (!beforeCosts || !afterCosts) { el.textContent = ''; return; }
+ var diff = beforeCosts.net_cost - afterCosts.net_cost;
+ var pct = beforeCosts.net_cost !== 0 ? (diff / Math.abs(beforeCosts.net_cost) * 100) : 0;
+ var sign = diff > 0 ? '\u2212' : '+';
+ el.textContent = sign + formatDollar(diff) + ' (' + Math.abs(pct).toFixed(1) + '%)';
+ el.className = 'modeling-col-cost ' + (diff > 0 ? 'diff-positive' : diff < 0 ? 'diff-negative' : '');
+ }
+
+ function showCostColumns(visible) {
+ var els = document.querySelectorAll('.modeling-col-cost');
+ for (var i = 0; i < els.length; i++) {
+ els[i].style.display = visible ? '' : 'none';
+ }
+ }
+
function computeEnergy(powerArr, resolutionS) {
// Split import (positive power = buying from grid) and
// export (negative power = selling/crediting to grid).
@@ -497,6 +698,231 @@
Modeling —
return getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#333';
}
+ // ---------------------------------------------------------------
+ // Opower helpers
+ // ---------------------------------------------------------------
+ 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 = '';
+ fetch('rates/opower-account')
+ .then(function(r) { return r.json(); })
+ .then(function(saved) {
+ if (saved.configured) {
+ showOpowerAccount(saved);
+ } else if (accounts.length === 1) {
+ 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';
+ }
+
+ // ---------------------------------------------------------------
+ // Rate plan helpers
+ // ---------------------------------------------------------------
+ function loadOpenEIConfig() {
+ fetch('rates/openei-config').then(function(r) { return r.json(); }).then(function(cfg) {
+ document.getElementById('rate-api-url').value = cfg.api_url || '';
+ document.getElementById('rate-api-key').value = cfg.api_key || '';
+ });
+ }
+
+ function loadCurrentRate() {
+ fetch('rates/current').then(function(r) { return r.json(); }).then(function(data) {
+ if (data.label) {
+ currentRateLabel = data.label;
+ currentDisplayEl.textContent = (data.utility || '') + ' \u2014 ' + (data.name || data.label);
+ attributionBtn.style.display = '';
+ refreshCurrentBtn.style.display = '';
+ document.getElementById('btn-rate-current-configure').textContent = 'Change';
+ } else {
+ currentRateLabel = null;
+ currentDisplayEl.textContent = 'No rate plan selected';
+ attributionBtn.style.display = 'none';
+ refreshCurrentBtn.style.display = 'none';
+ document.getElementById('btn-rate-current-configure').textContent = 'Configure';
+ }
+ });
+ }
+
+ function openRateDialog(target) {
+ rateDialogTarget = target;
+ dialogError.style.display = 'none';
+ dialogOverlay.style.display = 'flex';
+ loadOpenEIConfig();
+ var opowerAcct = null;
+ var opowerDisplay = opowerDisplayEl.textContent;
+ if (opowerDisplay && opowerSectionEl.style.display !== 'none') {
+ var parts = opowerDisplay.split('\u2014');
+ if (parts.length > 0) opowerAcct = parts[0].trim();
+ }
+ loadUtilities(opowerAcct || null);
+ }
+
+ function closeRateDialog() {
+ dialogOverlay.style.display = 'none';
+ }
+
+ 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 = '
Loading... ';
+ planSelect.innerHTML = '
Select a utility first ';
+ 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 = '
Error: ' + data.error + ' ';
+ return;
+ }
+ utilitySelect.innerHTML = '
Select a utility... ';
+ 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;
+ }
+ }
+ if (matchIndex > 0) {
+ utilitySelect.selectedIndex = matchIndex;
+ loadRatePlans(utilitySelect.value);
+ }
+ })
+ .catch(function() {
+ utilitySelect.innerHTML = '
Error loading utilities ';
+ });
+ }
+
+ function loadRatePlans(utility) {
+ planSelect.innerHTML = '
Loading plans... ';
+ planSelect.disabled = true;
+ useRateBtn.disabled = true;
+
+ fetch('rates/plans?utility=' + encodeURIComponent(utility))
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.error) {
+ planSelect.innerHTML = '
Error: ' + data.error + ' ';
+ return;
+ }
+ planSelect.innerHTML = '
Select a rate plan... ';
+ planSelect.disabled = false;
+ for (var i = 0; i < data.length; i++) {
+ var opt = document.createElement('option');
+ opt.value = data[i].label;
+ var dateStr = data[i].startdate ? ' (' + new Date(data[i].startdate * 1000).getFullYear() + ')' : '';
+ opt.textContent = data[i].name + dateStr;
+ planSelect.appendChild(opt);
+ }
+ })
+ .catch(function() {
+ planSelect.innerHTML = '
Error loading plans ';
+ });
+ }
+
+ function useSelectedRate() {
+ var label = planSelect.value;
+ if (!label) return;
+ useRateBtn.disabled = true;
+ dialogError.style.display = 'none';
+
+ fetch('rates/fetch', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({label: label}),
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.error) {
+ dialogError.textContent = data.error;
+ dialogError.style.display = '';
+ useRateBtn.disabled = false;
+ return;
+ }
+ if (rateDialogTarget === 'current') {
+ fetch('rates/current', {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({label: label}),
+ }).then(function() {
+ loadCurrentRate();
+ closeRateDialog();
+ fetchModelingData(horizonSelect.value);
+ });
+ } else {
+ proposedRateLabel = label;
+ proposedDisplayEl.textContent = (data.utility || '') + ' \u2014 ' + (data.name || label);
+ clearProposedBtn.style.display = '';
+ document.getElementById('btn-rate-proposed-set').textContent = 'Change';
+ closeRateDialog();
+ fetchModelingData(horizonSelect.value);
+ }
+ })
+ .catch(function(err) {
+ dialogError.textContent = 'Network error: ' + err.message;
+ dialogError.style.display = '';
+ useRateBtn.disabled = false;
+ });
+ }
+
// ---------------------------------------------------------------
// Circuit overlay support
// ---------------------------------------------------------------
@@ -524,6 +950,78 @@
Modeling —
pvCheckbox.addEventListener('change', function() { renderCharts(); });
batteryCheckbox.addEventListener('change', function() { renderCharts(); });
+ // ---------------------------------------------------------------
+ // Rate plan event listeners
+ // ---------------------------------------------------------------
+ document.getElementById('btn-rate-current-configure').addEventListener('click', function() {
+ openRateDialog('current');
+ });
+ document.getElementById('btn-rate-proposed-set').addEventListener('click', function() {
+ openRateDialog('proposed');
+ });
+ refreshCurrentBtn.addEventListener('click', function() {
+ if (!currentRateLabel) return;
+ refreshCurrentBtn.disabled = true;
+ fetch('rates/refresh', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({label: currentRateLabel}),
+ })
+ .then(function(r) { return r.json(); })
+ .then(function() {
+ refreshCurrentBtn.disabled = false;
+ fetchModelingData(horizonSelect.value);
+ })
+ .catch(function() { refreshCurrentBtn.disabled = false; });
+ });
+ clearProposedBtn.addEventListener('click', function() {
+ proposedRateLabel = null;
+ proposedDisplayEl.textContent = 'Using current rate for comparison';
+ clearProposedBtn.style.display = 'none';
+ document.getElementById('btn-rate-proposed-set').textContent = 'Set Proposed Rate';
+ fetchModelingData(horizonSelect.value);
+ });
+ document.getElementById('btn-rate-dialog-cancel').addEventListener('click', closeRateDialog);
+ 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); });
+ });
+ utilitySelect.addEventListener('change', function() {
+ if (utilitySelect.value) loadRatePlans(utilitySelect.value);
+ });
+ planSelect.addEventListener('change', function() {
+ useRateBtn.disabled = !planSelect.value;
+ });
+ document.getElementById('btn-rate-dialog-use').addEventListener('click', useSelectedRate);
+
+ // Opower event listeners
+ opowerChangeBtn.addEventListener('click', openOpowerPicker);
+ document.getElementById('btn-opower-picker-cancel').addEventListener('click', closeOpowerPicker);
+
+ // Attribution popup
+ attributionBtn.addEventListener('click', function() {
+ if (!currentRateLabel) return;
+ fetch('rates/attribution/' + encodeURIComponent(currentRateLabel))
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ var html = '
Provider: ' + (data.provider || 'Unknown') + '
'
+ + '
License: ' + (data.license || 'Unknown') + '
'
+ + '
URDB Label: ' + currentRateLabel + '
'
+ + '
Retrieved: ' + (data.retrieved_at || 'Unknown') + '
'
+ + '
View on OpenEI
';
+ document.getElementById('rate-attribution-content').innerHTML = html;
+ attrOverlay.style.display = 'flex';
+ });
+ });
+ document.getElementById('btn-attribution-close').addEventListener('click', function() {
+ attrOverlay.style.display = 'none';
+ });
+
// ---------------------------------------------------------------
// Auto-refresh on config changes
// ---------------------------------------------------------------
diff --git a/src/span_panel_simulator/ha_api/__init__.py b/src/span_panel_simulator/ha_api/__init__.py
index 1968e1f..56f6edb 100644
--- a/src/span_panel_simulator/ha_api/__init__.py
+++ b/src/span_panel_simulator/ha_api/__init__.py
@@ -17,10 +17,20 @@
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",
]
diff --git a/src/span_panel_simulator/ha_api/opower.py b/src/span_panel_simulator/ha_api/opower.py
new file mode 100644
index 0000000..569cd85
--- /dev/null
+++ b/src/span_panel_simulator/ha_api/opower.py
@@ -0,0 +1,149 @@
+"""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.
+ """
+ config_entries = await client._ws_command_list({"type": "config_entries/get"})
+ opower_entry_ids: set[str] = set()
+ opower_titles: dict[str, str] = {}
+ 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 []
+
+ devices = await client._ws_command_list({"type": "config/device_registry/list"})
+ elec_devices: list[tuple[str, str, str]] = []
+ 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, "")
+ 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 []
+
+ 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", ""))
+ if device_class == "monetary" and "cost_to_date" in ent_id:
+ cost_entity_id = ent_id
+ 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 isinstance(change, int | float):
+ 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)
diff --git a/src/span_panel_simulator/rates/__init__.py b/src/span_panel_simulator/rates/__init__.py
index 984a0a8..e515a91 100644
--- a/src/span_panel_simulator/rates/__init__.py
+++ b/src/span_panel_simulator/rates/__init__.py
@@ -1 +1,39 @@
"""ToU rate integration — OpenEI URDB rate plans and cost calculation."""
+
+from span_panel_simulator.rates.cache import RateCache
+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.rates.resolver import resolve_rate
+from span_panel_simulator.rates.types import (
+ AttributionMeta,
+ CostLedger,
+ OpenEIConfig,
+ RateCacheEntry,
+ RatePlanSummary,
+ URDBRateTier,
+ URDBRecord,
+ UtilitySummary,
+)
+
+__all__ = [
+ "AttributionMeta",
+ "CostLedger",
+ "OpenEIConfig",
+ "OpenEIError",
+ "RateCache",
+ "RateCacheEntry",
+ "RatePlanSummary",
+ "URDBRateTier",
+ "URDBRecord",
+ "UtilitySummary",
+ "compute_costs",
+ "fetch_rate_detail",
+ "fetch_rate_plans",
+ "fetch_utilities",
+ "resolve_rate",
+]
diff --git a/src/span_panel_simulator/rates/cache.py b/src/span_panel_simulator/rates/cache.py
new file mode 100644
index 0000000..80daa42
--- /dev/null
+++ b/src/span_panel_simulator/rates/cache.py
@@ -0,0 +1,186 @@
+"""Simulator-wide rate cache backed by a YAML file.
+
+Stores URDB records verbatim, keyed by their label. Also manages
+the current rate selection and OpenEI API configuration.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+import yaml
+
+from span_panel_simulator.rates.types import (
+ AttributionMeta,
+ OpenEIConfig,
+ RateCacheEntry,
+)
+
+_DEFAULT_ATTRIBUTION = AttributionMeta(
+ provider="OpenEI Utility Rate Database",
+ url="https://openei.org/wiki/Utility_Rate_Database",
+ license="CC0",
+ api_version=3,
+)
+
+
+class RateCache:
+ """Manages the simulator-wide rate cache YAML file."""
+
+ def __init__(self, path: Path) -> None:
+ self._path = path
+ self._data = self._load()
+
+ # -- Cache operations ------------------------------------------------
+
+ def get_cached_rate(self, label: str) -> RateCacheEntry | None:
+ """Return a cached rate entry by URDB label, or None."""
+ rates = self._data.get("rates", {})
+ entry = rates.get(label)
+ if entry is None:
+ return None
+ attr_data = entry.get("attribution", {})
+ return RateCacheEntry(
+ source=entry.get("source", "openei_urdb"),
+ retrieved_at=entry.get("retrieved_at", ""),
+ attribution=AttributionMeta(
+ provider=attr_data.get("provider", _DEFAULT_ATTRIBUTION.provider),
+ url=attr_data.get("url", _DEFAULT_ATTRIBUTION.url),
+ license=attr_data.get("license", _DEFAULT_ATTRIBUTION.license),
+ api_version=attr_data.get("api_version", _DEFAULT_ATTRIBUTION.api_version),
+ ),
+ record=entry.get("record", {}),
+ )
+
+ def cache_rate(self, label: str, urdb_record: dict[str, Any]) -> None:
+ """Store a URDB record in the cache."""
+ if "rates" not in self._data:
+ self._data["rates"] = {}
+ self._data["rates"][label] = {
+ "source": "openei_urdb",
+ "retrieved_at": datetime.now(UTC).isoformat(),
+ "attribution": {
+ "provider": _DEFAULT_ATTRIBUTION.provider,
+ "url": _DEFAULT_ATTRIBUTION.url,
+ "license": _DEFAULT_ATTRIBUTION.license,
+ "api_version": _DEFAULT_ATTRIBUTION.api_version,
+ },
+ "record": urdb_record,
+ }
+ self._save()
+
+ def delete_cached_rate(self, label: str) -> None:
+ """Remove a rate from the cache."""
+ rates = self._data.get("rates", {})
+ rates.pop(label, None)
+ self._save()
+
+ def list_cached_rates(self) -> list[dict[str, Any]]:
+ """Return summary dicts for all cached rates."""
+ rates = self._data.get("rates", {})
+ summaries = []
+ for label, entry in rates.items():
+ record = entry.get("record", {})
+ summaries.append(
+ {
+ "label": label,
+ "utility": record.get("utility", ""),
+ "name": record.get("name", ""),
+ "retrieved_at": entry.get("retrieved_at", ""),
+ }
+ )
+ return summaries
+
+ # -- Current rate selection ------------------------------------------
+
+ def get_current_rate_label(self) -> str | None:
+ """Return the simulator-wide current rate label, or None."""
+ label = self._data.get("current_rate_label")
+ return label if label else None
+
+ def set_current_rate_label(self, label: str) -> None:
+ """Set the simulator-wide current rate selection."""
+ self._data["current_rate_label"] = label
+ self._save()
+
+ # -- OpenEI configuration -------------------------------------------
+
+ def get_openei_config(self) -> OpenEIConfig:
+ """Return the stored OpenEI API settings."""
+ cfg = self._data.get("openei", {})
+ return OpenEIConfig(
+ api_url=cfg.get("api_url", OpenEIConfig.api_url),
+ api_key=cfg.get("api_key", OpenEIConfig.api_key),
+ )
+
+ def set_openei_config(self, api_url: str, api_key: str) -> None:
+ """Update the OpenEI API settings."""
+ self._data["openei"] = {
+ "api_url": api_url,
+ "api_key": api_key,
+ }
+ self._save()
+
+ # -- 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()
+
+ # -- Persistence -----------------------------------------------------
+
+ def _load(self) -> dict[str, Any]:
+ if self._path.exists():
+ with open(self._path) as f:
+ data = yaml.safe_load(f)
+ return data if isinstance(data, dict) else {}
+ return {}
+
+ def _save(self) -> None:
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ with open(self._path, "w") as f:
+ yaml.dump(
+ self._data,
+ f,
+ default_flow_style=False,
+ sort_keys=False,
+ allow_unicode=True,
+ )
diff --git a/src/span_panel_simulator/rates/cost_engine.py b/src/span_panel_simulator/rates/cost_engine.py
index dd5f3bb..b132886 100644
--- a/src/span_panel_simulator/rates/cost_engine.py
+++ b/src/span_panel_simulator/rates/cost_engine.py
@@ -16,7 +16,7 @@
def compute_costs(
timestamps: list[int],
- power_kw: list[float],
+ power_w: list[float],
record: dict[str, Any],
tz: str,
resolution_s: int = 3600,
@@ -27,8 +27,8 @@ def compute_costs(
----------
timestamps:
UNIX epoch seconds, one per interval (hourly).
- power_kw:
- Grid power per interval in kW. Positive = import, negative = export.
+ power_w:
+ Grid power per interval in Watts. Positive = import, negative = export.
record:
URDB record dict.
tz:
@@ -40,9 +40,10 @@ def compute_costs(
export_credit = 0.0
months_seen: set[tuple[int, int]] = set()
- for ts, pwr in zip(timestamps, power_kw, strict=False):
+ for ts, pwr in zip(timestamps, power_w, strict=True):
import_rate, export_rate = resolve_rate(ts, tz, record)
- energy_kwh = pwr * resolution_s / 3600
+ # Power arrays from the engine are in Watts; convert W * s → kWh.
+ energy_kwh = pwr * resolution_s / 3_600_000
if energy_kwh > 0:
import_cost += energy_kwh * import_rate
diff --git a/src/span_panel_simulator/rates/openei.py b/src/span_panel_simulator/rates/openei.py
new file mode 100644
index 0000000..d80c1a7
--- /dev/null
+++ b/src/span_panel_simulator/rates/openei.py
@@ -0,0 +1,143 @@
+"""OpenEI URDB API client.
+
+Fetches utility and rate plan data from the OpenEI Utility Rate
+Database. All functions accept api_url and api_key so the base URL
+and credentials are caller-configurable.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import aiohttp
+
+from span_panel_simulator.rates.types import RatePlanSummary, UtilitySummary
+
+_LOG = logging.getLogger(__name__)
+
+
+class OpenEIError(Exception):
+ """Raised when the URDB API returns an error or unexpected response."""
+
+
+async def _get_json(url: str, params: dict[str, str]) -> dict[str, Any]:
+ """Issue a GET request and return the parsed JSON response."""
+ async with aiohttp.ClientSession() as session, session.get(url, params=params) as resp:
+ if resp.status != 200:
+ body = await resp.text()
+ raise OpenEIError(f"HTTP {resp.status}: {body[:200]}")
+ data: dict[str, Any] = await resp.json(content_type=None)
+ return data
+
+
+async def fetch_utilities(
+ lat: float,
+ lon: float,
+ api_url: str,
+ api_key: str,
+) -> list[UtilitySummary]:
+ """Fetch utilities near a lat/lon from URDB.
+
+ Returns de-duplicated utilities sorted by name.
+ """
+ params = {
+ "version": "3",
+ "format": "json",
+ "api_key": api_key,
+ "lat": str(lat),
+ "lon": str(lon),
+ "sector": "Residential",
+ "detail": "minimal",
+ }
+ data = await _get_json(api_url, params)
+ items = data.get("items", [])
+
+ seen: set[str] = set()
+ utilities: list[UtilitySummary] = []
+ for item in items:
+ name = item.get("utility_name", item.get("utility", ""))
+ if not name or name in seen:
+ continue
+ seen.add(name)
+ utilities.append(
+ UtilitySummary(
+ utility_name=name,
+ eia_id=str(item.get("eia", "")),
+ )
+ )
+ utilities.sort(key=lambda u: u.utility_name)
+ return utilities
+
+
+async def fetch_rate_plans(
+ utility: str,
+ api_url: str,
+ api_key: str,
+ sector: str = "Residential",
+) -> list[RatePlanSummary]:
+ """Fetch available rate plans for a utility.
+
+ Returns only the latest version of each plan name (by startdate).
+ URDB often has multiple versions spanning 10+ years; users almost
+ always want the current rates.
+ """
+ params = {
+ "version": "3",
+ "format": "json",
+ "api_key": api_key,
+ "ratesforutility": utility,
+ "sector": sector,
+ "detail": "minimal",
+ }
+ data = await _get_json(api_url, params)
+ items = data.get("items", [])
+
+ # Keep only the latest version of each plan name.
+ latest_by_name: dict[str, dict[str, Any]] = {}
+ for item in items:
+ name: str = item.get("name", "")
+ startdate = int(item.get("startdate", 0) or 0)
+ existing = latest_by_name.get(name)
+ if existing is None or startdate > int(existing.get("startdate", 0) or 0):
+ latest_by_name[name] = item
+
+ plans: list[RatePlanSummary] = []
+ for item in latest_by_name.values():
+ enddate_raw = item.get("enddate")
+ plans.append(
+ RatePlanSummary(
+ label=str(item.get("label", "")),
+ name=str(item.get("name", "")),
+ startdate=int(item.get("startdate", 0) or 0),
+ enddate=int(enddate_raw) if enddate_raw is not None else None,
+ description=str(item.get("description", "")),
+ )
+ )
+ plans.sort(key=lambda p: p.name)
+ return plans
+
+
+async def fetch_rate_detail(
+ label: str,
+ api_url: str,
+ api_key: str,
+) -> dict[str, Any]:
+ """Fetch the full rate record for a URDB label.
+
+ Returns the raw URDB record dict (to be stored verbatim).
+ Raises OpenEIError if the label is not found.
+ """
+ params = {
+ "version": "3",
+ "format": "json",
+ "api_key": api_key,
+ "getpage": label,
+ "detail": "full",
+ }
+ data = await _get_json(api_url, params)
+ items = data.get("items", [])
+ if not items:
+ raise OpenEIError(f"Rate plan '{label}' not found in URDB")
+ record: dict[str, Any] = items[0]
+ return record
diff --git a/tests/test_ha_api/__init__.py b/tests/test_ha_api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_ha_api/test_opower.py b/tests/test_ha_api/test_opower.py
new file mode 100644
index 0000000..d4478d4
--- /dev/null
+++ b/tests/test_ha_api/test_opower.py
@@ -0,0 +1,259 @@
+"""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 (
+ 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
+
+
+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"},
+ {"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
diff --git a/tests/test_rates/test_cache.py b/tests/test_rates/test_cache.py
new file mode 100644
index 0000000..7788f7a
--- /dev/null
+++ b/tests/test_rates/test_cache.py
@@ -0,0 +1,158 @@
+"""Tests for the rate cache manager."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from span_panel_simulator.rates.cache import RateCache
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+SAMPLE_URDB_RECORD: dict = {
+ "label": "abc123",
+ "utility": "Pacific Gas & Electric Co",
+ "name": "E-TOU-C",
+ "energyratestructure": [[{"rate": 0.25}]],
+ "energyweekdayschedule": [[0] * 24 for _ in range(12)],
+ "energyweekendschedule": [[0] * 24 for _ in range(12)],
+}
+
+
+class TestRateCache:
+ """Rate cache load/save/get/set operations."""
+
+ def test_empty_cache_returns_none(self, tmp_path: Path) -> None:
+ cache = RateCache(tmp_path / "rates_cache.yaml")
+ assert cache.get_cached_rate("nonexistent") is None
+
+ def test_cache_and_retrieve(self, tmp_path: Path) -> None:
+ cache = RateCache(tmp_path / "rates_cache.yaml")
+ cache.cache_rate("abc123", SAMPLE_URDB_RECORD)
+ entry = cache.get_cached_rate("abc123")
+ assert entry is not None
+ assert entry.record["label"] == "abc123"
+ assert entry.record["utility"] == "Pacific Gas & Electric Co"
+ assert entry.source == "openei_urdb"
+ assert entry.attribution.license == "CC0"
+
+ def test_persistence_across_instances(self, tmp_path: Path) -> None:
+ path = tmp_path / "rates_cache.yaml"
+ cache1 = RateCache(path)
+ cache1.cache_rate("abc123", SAMPLE_URDB_RECORD)
+
+ cache2 = RateCache(path)
+ entry = cache2.get_cached_rate("abc123")
+ assert entry is not None
+ assert entry.record["name"] == "E-TOU-C"
+
+ def test_current_rate_label(self, tmp_path: Path) -> None:
+ cache = RateCache(tmp_path / "rates_cache.yaml")
+ assert cache.get_current_rate_label() is None
+ cache.set_current_rate_label("abc123")
+ assert cache.get_current_rate_label() == "abc123"
+
+ def test_current_rate_label_persists(self, tmp_path: Path) -> None:
+ path = tmp_path / "rates_cache.yaml"
+ cache1 = RateCache(path)
+ cache1.set_current_rate_label("abc123")
+
+ cache2 = RateCache(path)
+ assert cache2.get_current_rate_label() == "abc123"
+
+ def test_list_cached_rates(self, tmp_path: Path) -> None:
+ cache = RateCache(tmp_path / "rates_cache.yaml")
+ cache.cache_rate("abc123", SAMPLE_URDB_RECORD)
+ cache.cache_rate(
+ "def456",
+ {
+ "label": "def456",
+ "utility": "SoCal Edison",
+ "name": "TOU-D-PRIME",
+ },
+ )
+ summaries = cache.list_cached_rates()
+ assert len(summaries) == 2
+ labels = {s["label"] for s in summaries}
+ assert labels == {"abc123", "def456"}
+
+ def test_openei_config_defaults(self, tmp_path: Path) -> None:
+ cache = RateCache(tmp_path / "rates_cache.yaml")
+ config = cache.get_openei_config()
+ assert config.api_url == "https://api.openei.org/utility_rates"
+ assert config.api_key == ""
+
+ def test_openei_config_set_and_get(self, tmp_path: Path) -> None:
+ cache = RateCache(tmp_path / "rates_cache.yaml")
+ cache.set_openei_config("https://custom.api/rates", "my-key-123")
+ config = cache.get_openei_config()
+ assert config.api_url == "https://custom.api/rates"
+ assert config.api_key == "my-key-123"
+
+ def test_openei_config_persists(self, tmp_path: Path) -> None:
+ path = tmp_path / "rates_cache.yaml"
+ cache1 = RateCache(path)
+ cache1.set_openei_config("https://custom.api/rates", "my-key-123")
+
+ cache2 = RateCache(path)
+ config = cache2.get_openei_config()
+ assert config.api_url == "https://custom.api/rates"
+ assert config.api_key == "my-key-123"
+
+ def test_delete_cached_rate(self, tmp_path: Path) -> None:
+ cache = RateCache(tmp_path / "rates_cache.yaml")
+ cache.cache_rate("abc123", SAMPLE_URDB_RECORD)
+ assert cache.get_cached_rate("abc123") is not None
+ cache.delete_cached_rate("abc123")
+ assert cache.get_cached_rate("abc123") is None
+
+
+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
diff --git a/tests/test_rates/test_cost_engine.py b/tests/test_rates/test_cost_engine.py
index 98c6bb5..4e1b374 100644
--- a/tests/test_rates/test_cost_engine.py
+++ b/tests/test_rates/test_cost_engine.py
@@ -28,37 +28,43 @@ def _epoch(year: int, month: int, day: int, hour: int) -> int:
class TestComputeCosts:
- """Cost engine applies rates to hourly power arrays."""
+ """Cost engine applies rates to hourly power arrays (Watts)."""
def test_pure_import(self) -> None:
+ # 3 hours at 1000 W = 3 kWh * $0.20 = $0.60
ts = [_epoch(2026, 3, 15, h) for h in range(10, 13)]
- power = [1.0, 1.0, 1.0]
+ power = [1000.0, 1000.0, 1000.0]
result = compute_costs(ts, power, _FLAT_RECORD, TZ)
assert result.import_cost == pytest.approx(0.60)
assert result.export_credit == pytest.approx(0.0)
def test_pure_export(self) -> None:
+ # 2 hours at -2000 W (export) = 4 kWh * $0.05 = $0.20
ts = [_epoch(2026, 3, 15, h) for h in range(10, 12)]
- power = [-2.0, -2.0]
+ power = [-2000.0, -2000.0]
result = compute_costs(ts, power, _FLAT_RECORD, TZ)
assert result.import_cost == pytest.approx(0.0)
assert result.export_credit == pytest.approx(0.20)
def test_mixed_import_export(self) -> None:
+ # Hour 1: 1000 W import = 1 kWh * $0.20
+ # Hour 2: -1000 W export = 1 kWh * $0.05
ts = [_epoch(2026, 3, 15, 10), _epoch(2026, 3, 15, 11)]
- power = [1.0, -1.0]
+ power = [1000.0, -1000.0]
result = compute_costs(ts, power, _FLAT_RECORD, TZ)
assert result.import_cost == pytest.approx(0.20)
assert result.export_credit == pytest.approx(0.05)
def test_net_cost_includes_fixed(self) -> None:
+ # 1 hour at 1000 W = 1 kWh * $0.20 + $10 fixed = $10.20
ts = [_epoch(2026, 3, 15, 10)]
- power = [1.0]
+ power = [1000.0]
result = compute_costs(ts, power, _FLAT_RECORD, TZ)
assert result.fixed_charges == pytest.approx(10.0)
- assert result.net_cost == pytest.approx(0.20 - 0.0 + 10.0)
+ assert result.net_cost == pytest.approx(0.20 + 10.0)
def test_multi_month_fixed_charges(self) -> None:
+ # Hours spanning Jan and Feb -> 2 months -> $20 fixed
ts = [_epoch(2026, 1, 31, 23), _epoch(2026, 2, 1, 0)]
power = [0.0, 0.0]
result = compute_costs(ts, power, _FLAT_RECORD, TZ)
@@ -71,9 +77,10 @@ def test_no_fixed_charge_field(self) -> None:
"energyweekendschedule": [[0] * 24 for _ in range(12)],
}
ts = [_epoch(2026, 3, 15, 10)]
- power = [1.0]
+ power = [1000.0]
result = compute_costs(ts, power, record_no_fixed, TZ)
assert result.fixed_charges == pytest.approx(0.0)
+ assert result.import_cost == pytest.approx(0.10)
def test_zero_power(self) -> None:
ts = [_epoch(2026, 3, 15, h) for h in range(10, 14)]
@@ -98,6 +105,7 @@ def test_flat_demand_included_in_fixed(self) -> None:
"flatdemandstructure": [[{"rate": 8.0}]],
"flatdemandmonths": [[0] * 12],
}
+ # 1 month -> $5 fixed + $8 flat demand = $13
ts = [_epoch(2026, 3, 15, 10)]
power = [0.0]
result = compute_costs(ts, power, record_with_demand, TZ)
diff --git a/tests/test_rates/test_openei.py b/tests/test_rates/test_openei.py
new file mode 100644
index 0000000..470a615
--- /dev/null
+++ b/tests/test_rates/test_openei.py
@@ -0,0 +1,158 @@
+"""Tests for the OpenEI URDB API client (mocked HTTP)."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from span_panel_simulator.rates.openei import (
+ OpenEIError,
+ fetch_rate_detail,
+ fetch_rate_plans,
+ fetch_utilities,
+)
+
+API_URL = "https://api.openei.org/utility_rates"
+API_KEY = "test-key"
+
+
+class TestFetchUtilities:
+ """Fetch utilities by lat/lon."""
+
+ @pytest.mark.asyncio
+ async def test_returns_utility_summaries(self) -> None:
+ response_data = {
+ "items": [
+ {"utility_name": "Pacific Gas & Electric Co", "eia": "14328"},
+ {"utility_name": "City of Palo Alto", "eia": "14328"},
+ ]
+ }
+ with patch("span_panel_simulator.rates.openei._get_json", return_value=response_data):
+ result = await fetch_utilities(37.7, -122.4, API_URL, API_KEY)
+ assert len(result) >= 1
+ assert result[0].utility_name == "City of Palo Alto" # sorted alphabetically
+
+ @pytest.mark.asyncio
+ async def test_empty_result(self) -> None:
+ with patch("span_panel_simulator.rates.openei._get_json", return_value={"items": []}):
+ result = await fetch_utilities(0.0, 0.0, API_URL, API_KEY)
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_deduplicates_utilities(self) -> None:
+ response_data = {
+ "items": [
+ {"utility_name": "PG&E", "eia": "14328"},
+ {"utility_name": "PG&E", "eia": "14328"},
+ ]
+ }
+ with patch("span_panel_simulator.rates.openei._get_json", return_value=response_data):
+ result = await fetch_utilities(37.7, -122.4, API_URL, API_KEY)
+ assert len(result) == 1
+
+
+class TestFetchRatePlans:
+ """Fetch rate plans for a utility."""
+
+ @pytest.mark.asyncio
+ async def test_returns_plan_summaries(self) -> None:
+ response_data = {
+ "items": [
+ {
+ "label": "abc123",
+ "name": "E-TOU-C",
+ "startdate": 1672531200,
+ "enddate": None,
+ "description": "Time of use residential",
+ },
+ {
+ "label": "def456",
+ "name": "E-TOU-D",
+ "startdate": 1672531200,
+ "enddate": 1704067200,
+ "description": "Legacy TOU",
+ },
+ ]
+ }
+ with patch("span_panel_simulator.rates.openei._get_json", return_value=response_data):
+ result = await fetch_rate_plans("Pacific Gas & Electric Co", API_URL, API_KEY)
+ assert len(result) == 2
+ # Sorted by name
+ assert result[0].name == "E-TOU-C"
+ assert result[1].name == "E-TOU-D"
+
+ @pytest.mark.asyncio
+ async def test_keeps_only_latest_version_per_name(self) -> None:
+ response_data = {
+ "items": [
+ {
+ "label": "old_2020",
+ "name": "E-TOU-C",
+ "startdate": 1577836800,
+ "enddate": 1672531200,
+ "description": "2020 version",
+ },
+ {
+ "label": "current_2024",
+ "name": "E-TOU-C",
+ "startdate": 1704067200,
+ "enddate": None,
+ "description": "2024 version",
+ },
+ {
+ "label": "mid_2022",
+ "name": "E-TOU-C",
+ "startdate": 1640995200,
+ "enddate": 1704067200,
+ "description": "2022 version",
+ },
+ ]
+ }
+ with patch("span_panel_simulator.rates.openei._get_json", return_value=response_data):
+ result = await fetch_rate_plans("PG&E", API_URL, API_KEY)
+ assert len(result) == 1
+ assert result[0].label == "current_2024"
+ assert result[0].description == "2024 version"
+
+
+class TestFetchRateDetail:
+ """Fetch full rate detail by label."""
+
+ @pytest.mark.asyncio
+ async def test_returns_full_record(self) -> None:
+ response_data = {
+ "items": [
+ {
+ "label": "abc123",
+ "utility": "PG&E",
+ "name": "E-TOU-C",
+ "energyratestructure": [[{"rate": 0.25}]],
+ "energyweekdayschedule": [[0] * 24 for _ in range(12)],
+ "energyweekendschedule": [[0] * 24 for _ in range(12)],
+ }
+ ]
+ }
+ with patch("span_panel_simulator.rates.openei._get_json", return_value=response_data):
+ result = await fetch_rate_detail("abc123", API_URL, API_KEY)
+ assert result["label"] == "abc123"
+ assert result["energyratestructure"] == [[{"rate": 0.25}]]
+
+ @pytest.mark.asyncio
+ async def test_label_not_found_raises(self) -> None:
+ with (
+ patch("span_panel_simulator.rates.openei._get_json", return_value={"items": []}),
+ pytest.raises(OpenEIError, match="not found"),
+ ):
+ await fetch_rate_detail("nonexistent", API_URL, API_KEY)
+
+ @pytest.mark.asyncio
+ async def test_api_error_raises(self) -> None:
+ with (
+ patch(
+ "span_panel_simulator.rates.openei._get_json",
+ side_effect=OpenEIError("HTTP 401: Unauthorized"),
+ ),
+ pytest.raises(OpenEIError, match="401"),
+ ):
+ await fetch_rate_detail("abc123", API_URL, API_KEY)