From 107569cbb7bd0740c7cdda5e24467ad297986a96 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:20:56 -0700 Subject: [PATCH 01/22] Add rate cache manager for URDB record persistence --- src/span_panel_simulator/rates/cache.py | 146 ++++++++++++++++++++++++ tests/test_rates/test_cache.py | 107 +++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/span_panel_simulator/rates/cache.py create mode 100644 tests/test_rates/test_cache.py diff --git a/src/span_panel_simulator/rates/cache.py b/src/span_panel_simulator/rates/cache.py new file mode 100644 index 0000000..0689535 --- /dev/null +++ b/src/span_panel_simulator/rates/cache.py @@ -0,0 +1,146 @@ +"""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() + + # -- 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/tests/test_rates/test_cache.py b/tests/test_rates/test_cache.py new file mode 100644 index 0000000..782d7c0 --- /dev/null +++ b/tests/test_rates/test_cache.py @@ -0,0 +1,107 @@ +"""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 From f13566ac2a7ce0a0f3e637949c4a362d8aa572f3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:23:09 -0700 Subject: [PATCH 02/22] Add OpenEI URDB API client for rate plan discovery --- src/span_panel_simulator/rates/openei.py | 127 +++++++++++++++++++++++ tests/test_rates/test_openei.py | 125 ++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src/span_panel_simulator/rates/openei.py create mode 100644 tests/test_rates/test_openei.py diff --git a/src/span_panel_simulator/rates/openei.py b/src/span_panel_simulator/rates/openei.py new file mode 100644 index 0000000..46d7035 --- /dev/null +++ b/src/span_panel_simulator/rates/openei.py @@ -0,0 +1,127 @@ +"""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.""" + 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", []) + + plans: list[RatePlanSummary] = [] + for item in items: + plans.append( + RatePlanSummary( + label=item.get("label", ""), + name=item.get("name", ""), + startdate=item.get("startdate", 0), + enddate=item.get("enddate"), + description=item.get("description", ""), + ) + ) + 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_rates/test_openei.py b/tests/test_rates/test_openei.py new file mode 100644 index 0000000..255c8a2 --- /dev/null +++ b/tests/test_rates/test_openei.py @@ -0,0 +1,125 @@ +"""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 + assert result[0].label == "abc123" + assert result[0].name == "E-TOU-C" + assert result[1].enddate == 1704067200 + + +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) From 38c432004286e37b935b77d6417515265a958cd1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:32:47 -0700 Subject: [PATCH 03/22] Add rate API endpoints for URDB discovery, caching, and selection --- .../dashboard/__init__.py | 3 + src/span_panel_simulator/dashboard/keys.py | 2 + src/span_panel_simulator/dashboard/routes.py | 188 ++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/src/span_panel_simulator/dashboard/__init__.py b/src/span_panel_simulator/dashboard/__init__.py index 392f7b1..213507a 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_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..54e4e8b 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -25,6 +25,7 @@ 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 +34,18 @@ is_random_days_preset, match_battery_preset, ) +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 +85,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 +262,169 @@ 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, + } + ) + + def setup_routes(app: web.Application) -> None: """Register all dashboard routes.""" # Full page @@ -340,6 +515,19 @@ 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) + # -- Full page -- From e72d589ee26a2cd65f9d098bbbf76c72e1207410 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:34:28 -0700 Subject: [PATCH 04/22] Wire cost engine into modeling-data response Adds _attach_costs helper to handle_modeling_data route handler. Before costs use the simulator-wide current rate; After costs use the proposed rate if provided, otherwise the current rate. --- src/span_panel_simulator/dashboard/routes.py | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/span_panel_simulator/dashboard/routes.py b/src/span_panel_simulator/dashboard/routes.py index 54e4e8b..e4d49ed 100644 --- a/src/span_panel_simulator/dashboard/routes.py +++ b/src/span_panel_simulator/dashboard/routes.py @@ -34,6 +34,7 @@ is_random_days_preset, match_battery_preset, ) +from span_panel_simulator.rates.cost_engine import compute_costs from span_panel_simulator.rates.openei import ( OpenEIError, fetch_rate_detail, @@ -1198,9 +1199,54 @@ 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") + _attach_costs(result, cache, proposed_label) + return web.json_response(result) +def _attach_costs( + result: dict[str, Any], + cache: RateCache, + proposed_rate_label: str | None, +) -> None: + """Add before_costs and after_costs to a modeling result dict.""" + 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 + + tz_str: str = result["time_zone"] + ts_list: list[int] = result["timestamps"] + + before_costs = compute_costs(ts_list, result["site_power"], current_entry.record, tz_str) + result["before_costs"] = { + "import_cost": round(before_costs.import_cost, 2), + "export_credit": round(before_costs.export_credit, 2), + "fixed_charges": round(before_costs.fixed_charges, 2), + "net_cost": round(before_costs.net_cost, 2), + } + + # After: use proposed rate if set, otherwise current + 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 = compute_costs(ts_list, result["grid_power"], after_record, tz_str) + result["after_costs"] = { + "import_cost": round(after_costs.import_cost, 2), + "export_credit": round(after_costs.export_credit, 2), + "fixed_charges": round(after_costs.fixed_charges, 2), + "net_cost": round(after_costs.net_cost, 2), + } + + # -- File operations -- From a975d7cb6fada21775f51390eb1db6c8b289dd23 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:38:03 -0700 Subject: [PATCH 05/22] Add rate plan selection UI to modeling view Rate plan section with current/proposed rate slots, OpenEI rate selection dialog with utility/plan dropdowns and API config, and attribution popup. Wires into enterModelingMode/exitModelingMode and passes proposed_rate_label to modeling-data queries. --- .../templates/partials/modeling_view.html | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) 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..a84c79c 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,95 @@

Modeling —

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