From cb7e82b6df765fb63cf9b01d208288a43a2ba17f Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Mon, 11 May 2026 09:35:01 -0400 Subject: [PATCH 1/6] Add Rider 18 export rate support --- custom_components/dte_rates/__init__.py | 13 +- custom_components/dte_rates/const.py | 5 + custom_components/dte_rates/coordinator.py | 33 +++- custom_components/dte_rates/models.py | 2 + .../dte_rates/rate_calculator.py | 9 +- custom_components/dte_rates/rider18_parser.py | 175 ++++++++++++++++++ custom_components/dte_rates/sensor.py | 28 ++- docs/README.md | 9 + docs/research/rider18-export-rates.md | 29 +++ tests/test_rate_calculator.py | 7 + tests/test_rider18_parser.py | 82 ++++++++ tests/test_sensor.py | 39 +++- 12 files changed, 419 insertions(+), 12 deletions(-) create mode 100644 custom_components/dte_rates/rider18_parser.py create mode 100644 docs/README.md create mode 100644 docs/research/rider18-export-rates.md create mode 100644 tests/test_rider18_parser.py diff --git a/custom_components/dte_rates/__init__.py b/custom_components/dte_rates/__init__.py index a031064..66b595a 100644 --- a/custom_components/dte_rates/__init__.py +++ b/custom_components/dte_rates/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.components import persistent_notification -from .const import DOMAIN +from .const import CONF_NET_METERING, CONF_SELECTED_RATE, DOMAIN from .coordinator import DteRateCoordinator from .rate_calculator import current_export_rate_cents, current_import_rate_cents, period_display_name @@ -110,20 +110,25 @@ async def _handle_show_schedule_service(call) -> None: if coordinator is None: return + entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None) rate_code = call.data.get("rate_code") if rate_code: rate = coordinator.data.rates.get(rate_code) else: - entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None) - rate = coordinator.data.rates.get(entry.data.get("selected_rate")) if entry else None + rate = coordinator.data.rates.get(entry.data.get(CONF_SELECTED_RATE)) if entry else None if rate is None: return lines_by_season: dict[str, list[str]] = defaultdict(list) + net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False + rider18_rates = getattr(coordinator.data, "rider18_export_rates", {}) for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)): import_usd = float(current_import_rate_cents(period) / 100) - export_usd = float(current_export_rate_cents(period, False) / 100) + rider18_export_cents = None + if not net_metering: + rider18_export_cents = rider18_rates.get(rate.code, {}).get((period.season_name, period.period_name)) + export_usd = float(current_export_rate_cents(period, net_metering, rider18_export_cents) / 100) lines_by_season[period.season_name].append( f"{period_display_name(period)}: Import ${import_usd:.4f}/kWh | Export ${export_usd:.4f}/kWh" ) diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index f2adde1..a36c18b 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -6,6 +6,10 @@ "residential/Service-Request/pricing/residential-pricing-options/" "ResidentialElectricRateCard.pdf" ) +RIDER18_CALCULATOR_URL = ( + "https://www.dteenergy.com/content/dam/dteenergy/deg/website/" + "hybris/rooftop-solar/Rider18Calculator.xlsx" +) UPDATE_INTERVAL = timedelta(days=7) @@ -19,6 +23,7 @@ ATTR_COMPONENTS = "components" ATTR_MONTHLY_COMPONENTS = "monthly_components" ATTR_SOURCE_URL = "source_url" +ATTR_RIDER18_SOURCE_URL = "rider18_source_url" ATTR_CARD_EFFECTIVE_DATE = "card_effective_date" ATTR_SELECTED_RATE_AVAILABLE = "selected_rate_available" ATTR_WARNING = "warning" diff --git a/custom_components/dte_rates/coordinator.py b/custom_components/dte_rates/coordinator.py index f7e83e2..b28626d 100644 --- a/custom_components/dte_rates/coordinator.py +++ b/custom_components/dte_rates/coordinator.py @@ -7,9 +7,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import RATE_CARD_URL, UPDATE_INTERVAL +from .const import RATE_CARD_URL, RIDER18_CALCULATOR_URL, UPDATE_INTERVAL from .models import ParsedRateCard from .pdf_parser import parse_rate_card_pdf +from .rider18_parser import parse_rider18_xlsx _LOGGER = logging.getLogger(__name__) @@ -33,11 +34,39 @@ async def _async_update_data(self) -> ParsedRateCard: except Exception as err: raise UpdateFailed(f"Failed downloading DTE rate card: {err}") from err + rider18_bytes: bytes | None = None try: - return await self.hass.async_add_executor_job( + async with session.get(RIDER18_CALCULATOR_URL, timeout=60) as resp: + resp.raise_for_status() + rider18_bytes = await resp.read() + except Exception as err: + _LOGGER.warning( + "Failed downloading DTE Rider 18 calculator; export rates will fall back to generation-only values: %s", + err, + ) + + try: + parsed = await self.hass.async_add_executor_job( parse_rate_card_pdf, pdf_bytes, RATE_CARD_URL, ) except Exception as err: raise UpdateFailed(f"Failed parsing DTE rate card: {err}") from err + + if rider18_bytes is None: + return parsed + + try: + parsed.rider18_export_rates = await self.hass.async_add_executor_job( + parse_rider18_xlsx, + rider18_bytes, + ) + parsed.rider18_source_url = RIDER18_CALCULATOR_URL + except Exception as err: + _LOGGER.warning( + "Failed parsing DTE Rider 18 calculator; export rates will fall back to generation-only values: %s", + err, + ) + + return parsed diff --git a/custom_components/dte_rates/models.py b/custom_components/dte_rates/models.py index 144716e..6cfe9a7 100644 --- a/custom_components/dte_rates/models.py +++ b/custom_components/dte_rates/models.py @@ -50,3 +50,5 @@ class ParsedRateCard: effective_date: str | None rates: dict[str, RatePlan] raw_text_hash: str + rider18_source_url: str | None = None + rider18_export_rates: dict[str, dict[tuple[str, str], Decimal]] = field(default_factory=dict) diff --git a/custom_components/dte_rates/rate_calculator.py b/custom_components/dte_rates/rate_calculator.py index 36e4449..b505364 100644 --- a/custom_components/dte_rates/rate_calculator.py +++ b/custom_components/dte_rates/rate_calculator.py @@ -65,10 +65,17 @@ def current_import_rate_cents(period: SeasonalPeriodRate) -> Decimal: return period.components.per_kwh_total -def current_export_rate_cents(period: SeasonalPeriodRate, net_metering: bool) -> Decimal: +def current_export_rate_cents( + period: SeasonalPeriodRate, + net_metering: bool, + rider18_export_cents: Decimal | None = None, +) -> Decimal: if net_metering: return period.components.per_kwh_total + if rider18_export_cents is not None: + return rider18_export_cents + generation_only = Decimal("0") for key, value in period.components.per_kwh.items(): if any(marker in key for marker in GENERATION_COMPONENT_MARKERS): diff --git a/custom_components/dte_rates/rider18_parser.py b/custom_components/dte_rates/rider18_parser.py new file mode 100644 index 0000000..47a8cfb --- /dev/null +++ b/custom_components/dte_rates/rider18_parser.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +import posixpath +import re +from typing import Any +from xml.etree import ElementTree as ET +from zipfile import ZipFile +from io import BytesIO + + +_CELL_RE = re.compile(r"([A-Z]+)(\d+)") +_RATE_CODE_RE = re.compile(r"^[A-Z]\d+(?:\.\d+)?$") +_CREDIT_COLUMN = "D" + + +def parse_rider18_xlsx(xlsx_bytes: bytes) -> dict[str, dict[tuple[str, str], Decimal]]: + """Extract Rider 18 outflow credits as cents/kWh by rate, season, and period.""" + with ZipFile(BytesIO(xlsx_bytes)) as workbook: + shared_strings = _shared_strings(workbook) + sheet_path = _rates_sheet_path(workbook) + if sheet_path is None: + raise ValueError("Rider 18 workbook does not contain a Rates and Credits sheet") + + rows = _worksheet_rows(workbook, sheet_path, shared_strings) + + rates: dict[str, dict[tuple[str, str], Decimal]] = {} + current_rate_code: str | None = None + + for row_number in sorted(rows): + row = rows[row_number] + label = _clean_text(row.get("B")) + if not label: + continue + + if _RATE_CODE_RE.match(label): + current_rate_code = label + rates.setdefault(current_rate_code, {}) + continue + + if current_rate_code is None: + continue + + season_period = _season_period_from_label(label) + if season_period is None: + continue + + credit = _credit_cents(row.get(_CREDIT_COLUMN)) + if credit is None: + continue + + rates.setdefault(current_rate_code, {})[season_period] = credit + + return {code: values for code, values in rates.items() if values} + + +def _shared_strings(workbook: ZipFile) -> list[str]: + try: + root = ET.fromstring(workbook.read("xl/sharedStrings.xml")) + except KeyError: + return [] + + strings: list[str] = [] + for item in root.findall("{*}si"): + strings.append("".join(text.text or "" for text in item.findall(".//{*}t"))) + return strings + + +def _rates_sheet_path(workbook: ZipFile) -> str | None: + workbook_root = ET.fromstring(workbook.read("xl/workbook.xml")) + rels_root = ET.fromstring(workbook.read("xl/_rels/workbook.xml.rels")) + rel_targets = { + rel.attrib["Id"]: _normalize_target(rel.attrib.get("Target", "")) + for rel in rels_root.findall("{*}Relationship") + if "Id" in rel.attrib + } + + fallback: str | None = None + for sheet in workbook_root.findall(".//{*}sheet"): + name = sheet.attrib.get("name", "") + rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id") + target = rel_targets.get(rel_id or "") + if target is None: + continue + if name.strip().lower() == "rates and credits": + return target + if fallback is None and "rate" in name.lower(): + fallback = target + return fallback + + +def _normalize_target(target: str) -> str: + if target.startswith("/"): + return target.lstrip("/") + return posixpath.normpath(posixpath.join("xl", target)) + + +def _worksheet_rows( + workbook: ZipFile, + sheet_path: str, + shared_strings: list[str], +) -> dict[int, dict[str, str]]: + root = ET.fromstring(workbook.read(sheet_path)) + rows: dict[int, dict[str, str]] = {} + + for cell in root.findall(".//{*}c"): + ref = cell.attrib.get("r", "") + match = _CELL_RE.match(ref) + if not match: + continue + column, row_number_raw = match.groups() + value = _cell_value(cell, shared_strings) + if value is None: + continue + rows.setdefault(int(row_number_raw), {})[column] = value + + return rows + + +def _cell_value(cell: ET.Element, shared_strings: list[str]) -> str | None: + cell_type = cell.attrib.get("t") + if cell_type == "inlineStr": + return "".join(text.text or "" for text in cell.findall(".//{*}t")) + + value = cell.find("{*}v") + if value is None or value.text is None: + return None + + if cell_type == "s": + try: + return shared_strings[int(value.text)] + except (IndexError, ValueError): + return None + return value.text + + +def _clean_text(value: Any) -> str: + if value is None: + return "" + return " ".join(str(value).split()) + + +def _season_period_from_label(label: str) -> tuple[str, str] | None: + normalized = _clean_text(label).lower().replace("-", " ") + + if "june" in normalized or "summer" in normalized or "sept" in normalized: + season = "june_through_september" + elif "oct" in normalized or "winter" in normalized or "may" in normalized: + season = "october_through_may" + else: + return None + + if "super off peak" in normalized: + period = "super_off_peak" + elif "off peak" in normalized: + period = "off_peak" + elif "on peak" in normalized or "peak" in normalized: + period = "peak" + else: + return None + + return season, period + + +def _credit_cents(value: Any) -> Decimal | None: + text = _clean_text(value).replace("$", "").replace(",", "") + if not text: + return None + if text.startswith("(") and text.endswith(")"): + text = f"-{text[1:-1]}" + + try: + return (abs(Decimal(text)) * Decimal("100")).quantize(Decimal("0.001")) + except InvalidOperation: + return None diff --git a/custom_components/dte_rates/sensor.py b/custom_components/dte_rates/sensor.py index c6f5ab0..81622bf 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import defaultdict +from decimal import Decimal from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.components import persistent_notification @@ -24,6 +25,7 @@ ATTR_NEXT_RATE_VALUE, ATTR_RATE_CODE, ATTR_RATE_NAME, + ATTR_RIDER18_SOURCE_URL, ATTR_SCHEDULE_BY_SEASON, ATTR_SCHEDULE_TEXT, ATTR_SEASON, @@ -103,6 +105,24 @@ def _active_period(self) -> SeasonalPeriodRate | None: return None return get_active_period(rate, dt_util.now()) + def _rider18_export_rate_cents(self, period: SeasonalPeriodRate) -> Decimal | None: + if self._entry.data.get(CONF_NET_METERING, False): + return None + + rate = self._selected_rate() + if rate is None: + return None + + rider18_rates = getattr(self.coordinator.data, "rider18_export_rates", {}) + return rider18_rates.get(rate.code, {}).get((period.season_name, period.period_name)) + + def _export_rate_cents(self, period: SeasonalPeriodRate): + return current_export_rate_cents( + period, + self._entry.data.get(CONF_NET_METERING, False), + self._rider18_export_rate_cents(period), + ) + def _warning(self) -> str | None: selected = self._entry.data[CONF_SELECTED_RATE] if selected not in self.coordinator.data.rates: @@ -118,6 +138,8 @@ def _base_attributes(self) -> dict: ATTR_SOURCE_URL: self.coordinator.data.source_url, ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date, } + if self.coordinator.data.rider18_source_url: + attrs[ATTR_RIDER18_SOURCE_URL] = self.coordinator.data.rider18_source_url rate = self._selected_rate() period = self._active_period() @@ -265,8 +287,7 @@ def extra_state_attributes(self) -> dict: def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None: if period is None: return None - cents = current_export_rate_cents(period, self._entry.data.get(CONF_NET_METERING, False)) - return float(cents / 100) + return float(self._export_rate_cents(period) / 100) class DteCurrentRateNameSensor(_DteBaseRateSensor): @@ -328,7 +349,6 @@ def extra_state_attributes(self) -> dict: def _schedule_rows(self, rate: RatePlan) -> list[dict]: rows: list[dict] = [] - net_metering = self._entry.data.get(CONF_NET_METERING, False) for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)): rows.append( { @@ -337,7 +357,7 @@ def _schedule_rows(self, rate: RatePlan) -> list[dict]: "name": period_display_name(period), "time_window": self._window_summary(period), "import_usd_per_kwh": round(float(current_import_rate_cents(period) / 100), 6), - "export_usd_per_kwh": round(float(current_export_rate_cents(period, net_metering) / 100), 6), + "export_usd_per_kwh": round(float(self._export_rate_cents(period) / 100), 6), } ) return rows diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b9b7ac4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,9 @@ +# Project Notes + +This directory stores research and implementation notes that support changes to the DTE Rates integration. + +## Structure + +- `research/` - Source observations, mapping decisions, and implementation notes gathered while working on external rate-card or calculator data. + +Use these notes as context for why parsers map external DTE documents into Home Assistant entities. The code remains the source of truth for behavior. diff --git a/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md new file mode 100644 index 0000000..3ffd3b9 --- /dev/null +++ b/docs/research/rider18-export-rates.md @@ -0,0 +1,29 @@ +# Rider 18 Export Rates + +## Source + +- Enhancement request: use DTE Rider 18 calculator rates for export calculations. +- Workbook URL: `https://www.dteenergy.com/content/dam/dteenergy/deg/website/hybris/rooftop-solar/Rider18Calculator.xlsx` + +## Workbook Findings + +The workbook has a `Rates and Credits` sheet with Rider 18 credit values. The relevant table is labeled `FULL SERVICE - Rider 18 Credits`. + +Parser validation on May 11, 2026 downloaded the workbook successfully and extracted D1.11 Rider 18 outflow credits from that sheet. + +Observed D1.11 rows: + +| Workbook label | Integration season | Integration period | Outflow credit incl. PSCR | +| --- | --- | --- | --- | +| `June-Sept On Peak` | `june_through_september` | `peak` | `$0.16284/kWh` | +| `June-Sept Off Peak` | `june_through_september` | `off_peak` | `$0.10586/kWh` | +| `Oct-May On Peak` | `october_through_may` | `peak` | `$0.12196/kWh` | +| `Oct-May Off Peak` | `october_through_may` | `off_peak` | `$0.10586/kWh` | + +The workbook stores credits as negative dollars per kWh. The integration converts them to positive cents per kWh internally so they flow through the same calculator path as PDF-derived rates. + +## Implementation Decision + +For non-net-metering export calculations, prefer the workbook `Outflow Cred. Incl. PSCR` column when a matching rate, season, and period exists. If Rider 18 data is unavailable or does not contain a matching period, fall back to the PDF generation-only calculation. + +For net metering, keep the existing behavior: export uses the full active import rate from the selected rate plan. diff --git a/tests/test_rate_calculator.py b/tests/test_rate_calculator.py index 2a68008..d7ab4ae 100644 --- a/tests/test_rate_calculator.py +++ b/tests/test_rate_calculator.py @@ -74,6 +74,13 @@ def test_export_without_net_metering_only_generation(): assert current_export_rate_cents(active, net_metering=False) == Decimal("14.407") +def test_export_without_net_metering_prefers_rider18_credit(): + rate = _rate_plan() + active = get_active_period(rate, datetime(2026, 6, 1, 16, 30)) + assert active is not None + assert current_export_rate_cents(active, net_metering=False, rider18_export_cents=Decimal("16.284")) == Decimal("16.284") + + def test_export_with_net_metering_uses_total(): rate = _rate_plan() active = get_active_period(rate, datetime(2026, 6, 1, 16, 30)) diff --git a/tests/test_rider18_parser.py b/tests/test_rider18_parser.py new file mode 100644 index 0000000..ca2fee6 --- /dev/null +++ b/tests/test_rider18_parser.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from decimal import Decimal +from html import escape +from io import BytesIO +from zipfile import ZipFile + +from custom_components.dte_rates.rider18_parser import parse_rider18_xlsx + + +def _xlsx_with_rates() -> bytes: + cells = { + "B2": "FULL SERVICE - Rider 18 Credits", + "G2": "FULL SERVICE - D1.11 Rate", + "B4": "PSCR (November 1, 2024)", + "C4": "0.01877", + "C6": "Rider 18 Tariff (No PSCR)", + "D6": "Outflow Cred. Incl. PSCR", + "B7": "D1.11", + "B8": "June-Sept On Peak", + "C8": "-0.14407", + "D8": "-0.16284000000000001", + "B9": "June-Sept Off Peak", + "D9": "-0.10586", + "B10": "Oct-May On Peak", + "D10": "-0.12196", + "B11": "Oct-May Off Peak", + "D11": "-0.10586", + } + rows: dict[int, list[str]] = {} + for ref, value in cells.items(): + row = int("".join(ch for ch in ref if ch.isdigit())) + if value.replace(".", "", 1).replace("-", "", 1).isdigit(): + cell = f'{value}' + else: + cell = f'{escape(value)}' + rows.setdefault(row, []).append(cell) + + sheet_data = "".join(f'{"".join(cells)}' for row, cells in sorted(rows.items())) + workbook_xml = ( + '' + "" + '' + '' + "" + "" + ) + rels_xml = ( + '' + '' + '' + "" + ) + sheet_xml = ( + '' + f"{sheet_data}" + "" + ) + + buffer = BytesIO() + with ZipFile(buffer, "w") as workbook: + workbook.writestr("xl/workbook.xml", workbook_xml) + workbook.writestr("xl/_rels/workbook.xml.rels", rels_xml) + workbook.writestr("xl/worksheets/sheet1.xml", "") + workbook.writestr("xl/worksheets/sheet3.xml", sheet_xml) + return buffer.getvalue() + + +def test_parse_rider18_xlsx_extracts_outflow_credits_in_cents(): + rates = parse_rider18_xlsx(_xlsx_with_rates()) + + assert rates == { + "D1.11": { + ("june_through_september", "peak"): Decimal("16.284"), + ("june_through_september", "off_peak"): Decimal("10.586"), + ("october_through_may", "peak"): Decimal("12.196"), + ("october_through_may", "off_peak"): Decimal("10.586"), + } + } diff --git a/tests/test_sensor.py b/tests/test_sensor.py index c1c4a0d..dabf8be 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -17,7 +17,9 @@ ) -def _coordinator_with_rate() -> SimpleNamespace: +def _coordinator_with_rate( + rider18_export_rates: dict[tuple[str, str], Decimal] | None = None, +) -> SimpleNamespace: rate = RatePlan( code="D1.11", name="Standard Base", @@ -42,6 +44,8 @@ def _coordinator_with_rate() -> SimpleNamespace: effective_date="February 6, 2025", rates={"D1.11": rate}, raw_text_hash="hash", + rider18_source_url="https://example.test/Rider18Calculator.xlsx" if rider18_export_rates else None, + rider18_export_rates={"D1.11": rider18_export_rates or {}}, ) ) @@ -71,6 +75,27 @@ def test_export_sensor_uses_generation_only_without_net_metering(monkeypatch): assert sensor.extra_state_attributes["next_rate_value"] is None +def test_export_sensor_prefers_rider18_credit_without_net_metering(monkeypatch): + monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) + + coordinator = _coordinator_with_rate({("year_round", "all_kwh"): Decimal("10.586")}) + entry = SimpleNamespace(entry_id="entry_13", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) + + sensor = DteExportRateSensor(coordinator, entry) + assert sensor.native_value == 0.10586 + assert sensor.extra_state_attributes["rider18_source_url"] == "https://example.test/Rider18Calculator.xlsx" + + +def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): + monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) + + coordinator = _coordinator_with_rate({("year_round", "all_kwh"): Decimal("10.586")}) + entry = SimpleNamespace(entry_id="entry_14", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: True}) + + sensor = DteExportRateSensor(coordinator, entry) + assert sensor.native_value == 0.06 + + def test_sensor_warns_when_selected_rate_disappears(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) @@ -134,6 +159,18 @@ def test_schedule_sensor_exposes_full_schedule(monkeypatch): assert attrs["next_rate_value"] is None +def test_schedule_sensor_uses_rider18_export_credit(monkeypatch): + monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) + coordinator = _coordinator_with_rate({("year_round", "all_kwh"): Decimal("10.586")}) + entry = SimpleNamespace(entry_id="entry_15", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) + + sensor = DteRateScheduleSensor(coordinator, entry) + attrs = sensor.extra_state_attributes + + assert "Export $0.1059/kWh" in attrs["schedule_text"] + assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.10586 + + def test_schedule_sensor_next_rate_value_defaults_to_import(monkeypatch): now = datetime(2026, 3, 1, 12, 0) monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: now) From 2575c4f09076c7ecf07b3067341afc7772180ef9 Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Mon, 11 May 2026 09:40:22 -0400 Subject: [PATCH 2/6] Expose Rider 18 export status --- custom_components/dte_rates/config_flow.py | 21 +++++++++++- custom_components/dte_rates/const.py | 3 ++ custom_components/dte_rates/sensor.py | 29 +++++++++++++++++ custom_components/dte_rates/strings.json | 2 +- .../dte_rates/translations/en.json | 2 +- tests/conftest.py | 2 +- tests/test_config_flow.py | 32 +++++++++++++++++++ tests/test_sensor.py | 18 +++++++++++ 8 files changed, 105 insertions(+), 4 deletions(-) diff --git a/custom_components/dte_rates/config_flow.py b/custom_components/dte_rates/config_flow.py index 0044d9f..4ff5d50 100644 --- a/custom_components/dte_rates/config_flow.py +++ b/custom_components/dte_rates/config_flow.py @@ -6,6 +6,7 @@ from .const import CONF_NET_METERING, CONF_SELECTED_RATE, DOMAIN from .coordinator import DteRateCoordinator +from .models import ParsedRateCard class DteRatesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -35,4 +36,22 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: vol.Optional(CONF_NET_METERING, default=False): bool, } ) - return self.async_show_form(step_id="user", data_schema=schema) + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={ + "rider18_status": _rider18_status(coordinator.data), + }, + ) + + +def _rider18_status(rate_card: ParsedRateCard) -> str: + count = len(rate_card.rider18_export_rates) + if count: + suffix = "rate plan" if count == 1 else "rate plans" + return f"Rider 18 export credits loaded for {count} {suffix}." + + if rate_card.rider18_source_url: + return "Rider 18 calculator loaded, but no export credits matched the parsed rate plans." + + return "Rider 18 export credits are not loaded; non-net-metering exports will fall back to PDF generation-only pricing." diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index a36c18b..36f8205 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -24,6 +24,9 @@ ATTR_MONTHLY_COMPONENTS = "monthly_components" ATTR_SOURCE_URL = "source_url" ATTR_RIDER18_SOURCE_URL = "rider18_source_url" +ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available" +ATTR_EXPORT_RATE_SOURCE = "export_rate_source" +ATTR_EXPORT_RATE_WARNING = "export_rate_warning" ATTR_CARD_EFFECTIVE_DATE = "card_effective_date" ATTR_SELECTED_RATE_AVAILABLE = "selected_rate_available" ATTR_WARNING = "warning" diff --git a/custom_components/dte_rates/sensor.py b/custom_components/dte_rates/sensor.py index 81622bf..db53766 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -20,11 +20,14 @@ ATTR_MONTHLY_COMPONENTS, ATTR_PERIOD, ATTR_CURRENT_RATE_NAME, + ATTR_EXPORT_RATE_SOURCE, + ATTR_EXPORT_RATE_WARNING, ATTR_NEXT_RATE_CHANGE, ATTR_NEXT_RATE_NAME, ATTR_NEXT_RATE_VALUE, ATTR_RATE_CODE, ATTR_RATE_NAME, + ATTR_RIDER18_EXPORT_AVAILABLE, ATTR_RIDER18_SOURCE_URL, ATTR_SCHEDULE_BY_SEASON, ATTR_SCHEDULE_TEXT, @@ -123,6 +126,25 @@ def _export_rate_cents(self, period: SeasonalPeriodRate): self._rider18_export_rate_cents(period), ) + def _export_rate_source(self, period: SeasonalPeriodRate) -> str: + if self._entry.data.get(CONF_NET_METERING, False): + return "net_metering" + if self._rider18_export_rate_cents(period) is not None: + return "rider18" + return "pdf_generation_components" + + def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None: + if self._entry.data.get(CONF_NET_METERING, False): + return None + if self._rider18_export_rate_cents(period) is not None: + return None + if not self.coordinator.data.rider18_source_url: + return "Rider 18 export credits are not loaded; using PDF generation-only export pricing." + return ( + "No Rider 18 export credit matched the selected rate's active season and period; " + "using PDF generation-only export pricing." + ) + def _warning(self) -> str | None: selected = self._entry.data[CONF_SELECTED_RATE] if selected not in self.coordinator.data.rates: @@ -282,6 +304,13 @@ def native_value(self) -> float | None: def extra_state_attributes(self) -> dict: attrs = self._base_attributes() attrs[CONF_NET_METERING] = self._entry.data.get(CONF_NET_METERING, False) + period = self._active_period() + if period is not None: + attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period) + attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = self._rider18_export_rate_cents(period) is not None + warning = self._export_rate_warning(period) + if warning is not None: + attrs[ATTR_EXPORT_RATE_WARNING] = warning return attrs def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None: diff --git a/custom_components/dte_rates/strings.json b/custom_components/dte_rates/strings.json index e5f4afa..7de034f 100644 --- a/custom_components/dte_rates/strings.json +++ b/custom_components/dte_rates/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "DTE Residential Rates", - "description": "Choose your DTE residential electric pricing plan.", + "description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}", "data": { "selected_rate": "Rate plan", "net_metering": "Net metering enabled" diff --git a/custom_components/dte_rates/translations/en.json b/custom_components/dte_rates/translations/en.json index e5f4afa..7de034f 100644 --- a/custom_components/dte_rates/translations/en.json +++ b/custom_components/dte_rates/translations/en.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "DTE Residential Rates", - "description": "Choose your DTE residential electric pricing plan.", + "description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}", "data": { "selected_rate": "Rate plan", "net_metering": "Net metering enabled" diff --git a/tests/conftest.py b/tests/conftest.py index 799dab7..b1935dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,7 +32,7 @@ def __init__(self): self.hass = None def async_show_form(self, *, step_id, data_schema=None, errors=None, **kwargs): - return FlowResult(type="form", step_id=step_id, data_schema=data_schema, errors=errors or {}) + return FlowResult(type="form", step_id=step_id, data_schema=data_schema, errors=errors or {}, **kwargs) def async_create_entry(self, *, title, data): return FlowResult(type="create_entry", title=title, data=data) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e8afcf7..9494357 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,5 +1,6 @@ from __future__ import annotations +from decimal import Decimal from unittest.mock import MagicMock import pytest @@ -41,3 +42,34 @@ async def _refresh(self): assert result["title"] == "Overnight (D1.13)" assert result["data"][CONF_SELECTED_RATE] == "D1.13" assert result["data"][CONF_NET_METERING] is True + + +@pytest.mark.asyncio +async def test_config_flow_describes_rider18_loaded_status(monkeypatch): + flow = DteRatesConfigFlow() + flow.hass = MagicMock() + + async def _refresh(self): + self.data = ParsedRateCard( + source_url="https://example.test/card.pdf", + effective_date="February 6, 2025", + rates={ + "D1.11": RatePlan(code="D1.11", name="Standard Base", periods=[]), + "D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]), + }, + raw_text_hash="abc", + rider18_source_url="https://example.test/Rider18Calculator.xlsx", + rider18_export_rates={ + "D1.11": {("october_through_may", "off_peak"): Decimal("10.586")}, + }, + ) + + monkeypatch.setattr( + "custom_components.dte_rates.coordinator.DteRateCoordinator.async_refresh", + _refresh, + ) + + result = await flow.async_step_user() + + assert result["type"] == "form" + assert result["description_placeholders"]["rider18_status"] == "Rider 18 export credits loaded for 1 rate plan." diff --git a/tests/test_sensor.py b/tests/test_sensor.py index dabf8be..9fded3f 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -84,6 +84,8 @@ def test_export_sensor_prefers_rider18_credit_without_net_metering(monkeypatch): sensor = DteExportRateSensor(coordinator, entry) assert sensor.native_value == 0.10586 assert sensor.extra_state_attributes["rider18_source_url"] == "https://example.test/Rider18Calculator.xlsx" + assert sensor.extra_state_attributes["export_rate_source"] == "rider18" + assert sensor.extra_state_attributes["rider18_export_available"] is True def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): @@ -94,6 +96,22 @@ def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): sensor = DteExportRateSensor(coordinator, entry) assert sensor.native_value == 0.06 + assert sensor.extra_state_attributes["export_rate_source"] == "net_metering" + + +def test_export_sensor_reports_generation_fallback_when_rider18_missing(monkeypatch): + monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) + + coordinator = _coordinator_with_rate({("june_through_september", "peak"): Decimal("16.284")}) + entry = SimpleNamespace(entry_id="entry_16", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) + + sensor = DteExportRateSensor(coordinator, entry) + attrs = sensor.extra_state_attributes + + assert sensor.native_value == 0.03 + assert attrs["export_rate_source"] == "pdf_generation_components" + assert attrs["rider18_export_available"] is False + assert "No Rider 18 export credit" in attrs["export_rate_warning"] def test_sensor_warns_when_selected_rate_disappears(monkeypatch): From 2713b07fbedc62b6331b60b7bfa6065170c8a64c Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Mon, 11 May 2026 10:16:04 -0400 Subject: [PATCH 3/6] Calculate Rider 18 export from rate card --- custom_components/dte_rates/__init__.py | 6 +- custom_components/dte_rates/config_flow.py | 13 +- custom_components/dte_rates/const.py | 5 - custom_components/dte_rates/coordinator.py | 33 +--- custom_components/dte_rates/models.py | 2 - .../dte_rates/rate_calculator.py | 42 +++-- custom_components/dte_rates/rider18_parser.py | 175 ------------------ custom_components/dte_rates/sensor.py | 40 +--- docs/research/rider18-export-rates.md | 31 ++-- tests/test_config_flow.py | 12 +- tests/test_rate_calculator.py | 25 ++- tests/test_rider18_parser.py | 82 -------- tests/test_sensor.py | 46 ++--- 13 files changed, 97 insertions(+), 415 deletions(-) delete mode 100644 custom_components/dte_rates/rider18_parser.py delete mode 100644 tests/test_rider18_parser.py diff --git a/custom_components/dte_rates/__init__.py b/custom_components/dte_rates/__init__.py index 66b595a..14557d0 100644 --- a/custom_components/dte_rates/__init__.py +++ b/custom_components/dte_rates/__init__.py @@ -122,13 +122,9 @@ async def _handle_show_schedule_service(call) -> None: lines_by_season: dict[str, list[str]] = defaultdict(list) net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False - rider18_rates = getattr(coordinator.data, "rider18_export_rates", {}) for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)): import_usd = float(current_import_rate_cents(period) / 100) - rider18_export_cents = None - if not net_metering: - rider18_export_cents = rider18_rates.get(rate.code, {}).get((period.season_name, period.period_name)) - export_usd = float(current_export_rate_cents(period, net_metering, rider18_export_cents) / 100) + export_usd = float(current_export_rate_cents(period, net_metering) / 100) lines_by_season[period.season_name].append( f"{period_display_name(period)}: Import ${import_usd:.4f}/kWh | Export ${export_usd:.4f}/kWh" ) diff --git a/custom_components/dte_rates/config_flow.py b/custom_components/dte_rates/config_flow.py index 4ff5d50..b03edfd 100644 --- a/custom_components/dte_rates/config_flow.py +++ b/custom_components/dte_rates/config_flow.py @@ -6,7 +6,6 @@ from .const import CONF_NET_METERING, CONF_SELECTED_RATE, DOMAIN from .coordinator import DteRateCoordinator -from .models import ParsedRateCard class DteRatesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -45,13 +44,5 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: ) -def _rider18_status(rate_card: ParsedRateCard) -> str: - count = len(rate_card.rider18_export_rates) - if count: - suffix = "rate plan" if count == 1 else "rate plans" - return f"Rider 18 export credits loaded for {count} {suffix}." - - if rate_card.rider18_source_url: - return "Rider 18 calculator loaded, but no export credits matched the parsed rate plans." - - return "Rider 18 export credits are not loaded; non-net-metering exports will fall back to PDF generation-only pricing." +def _rider18_status(_rate_card) -> str: + return "Rider 18 export credits are calculated from the parsed rate card as Generation + Distribution/Transmission." diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index 36f8205..aec2d9e 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -6,10 +6,6 @@ "residential/Service-Request/pricing/residential-pricing-options/" "ResidentialElectricRateCard.pdf" ) -RIDER18_CALCULATOR_URL = ( - "https://www.dteenergy.com/content/dam/dteenergy/deg/website/" - "hybris/rooftop-solar/Rider18Calculator.xlsx" -) UPDATE_INTERVAL = timedelta(days=7) @@ -23,7 +19,6 @@ ATTR_COMPONENTS = "components" ATTR_MONTHLY_COMPONENTS = "monthly_components" ATTR_SOURCE_URL = "source_url" -ATTR_RIDER18_SOURCE_URL = "rider18_source_url" ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available" ATTR_EXPORT_RATE_SOURCE = "export_rate_source" ATTR_EXPORT_RATE_WARNING = "export_rate_warning" diff --git a/custom_components/dte_rates/coordinator.py b/custom_components/dte_rates/coordinator.py index b28626d..f7e83e2 100644 --- a/custom_components/dte_rates/coordinator.py +++ b/custom_components/dte_rates/coordinator.py @@ -7,10 +7,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import RATE_CARD_URL, RIDER18_CALCULATOR_URL, UPDATE_INTERVAL +from .const import RATE_CARD_URL, UPDATE_INTERVAL from .models import ParsedRateCard from .pdf_parser import parse_rate_card_pdf -from .rider18_parser import parse_rider18_xlsx _LOGGER = logging.getLogger(__name__) @@ -34,39 +33,11 @@ async def _async_update_data(self) -> ParsedRateCard: except Exception as err: raise UpdateFailed(f"Failed downloading DTE rate card: {err}") from err - rider18_bytes: bytes | None = None try: - async with session.get(RIDER18_CALCULATOR_URL, timeout=60) as resp: - resp.raise_for_status() - rider18_bytes = await resp.read() - except Exception as err: - _LOGGER.warning( - "Failed downloading DTE Rider 18 calculator; export rates will fall back to generation-only values: %s", - err, - ) - - try: - parsed = await self.hass.async_add_executor_job( + return await self.hass.async_add_executor_job( parse_rate_card_pdf, pdf_bytes, RATE_CARD_URL, ) except Exception as err: raise UpdateFailed(f"Failed parsing DTE rate card: {err}") from err - - if rider18_bytes is None: - return parsed - - try: - parsed.rider18_export_rates = await self.hass.async_add_executor_job( - parse_rider18_xlsx, - rider18_bytes, - ) - parsed.rider18_source_url = RIDER18_CALCULATOR_URL - except Exception as err: - _LOGGER.warning( - "Failed parsing DTE Rider 18 calculator; export rates will fall back to generation-only values: %s", - err, - ) - - return parsed diff --git a/custom_components/dte_rates/models.py b/custom_components/dte_rates/models.py index 6cfe9a7..144716e 100644 --- a/custom_components/dte_rates/models.py +++ b/custom_components/dte_rates/models.py @@ -50,5 +50,3 @@ class ParsedRateCard: effective_date: str | None rates: dict[str, RatePlan] raw_text_hash: str - rider18_source_url: str | None = None - rider18_export_rates: dict[str, dict[tuple[str, str], Decimal]] = field(default_factory=dict) diff --git a/custom_components/dte_rates/rate_calculator.py b/custom_components/dte_rates/rate_calculator.py index b505364..a61a552 100644 --- a/custom_components/dte_rates/rate_calculator.py +++ b/custom_components/dte_rates/rate_calculator.py @@ -7,6 +7,7 @@ GENERATION_COMPONENT_MARKERS = ("capacity_energy", "non_capacity_energy") +DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS = ("distribution", "transmission") def _is_hour_in_range(start_hour: int, end_hour: int, hour: int) -> bool: @@ -65,22 +66,37 @@ def current_import_rate_cents(period: SeasonalPeriodRate) -> Decimal: return period.components.per_kwh_total -def current_export_rate_cents( - period: SeasonalPeriodRate, - net_metering: bool, - rider18_export_cents: Decimal | None = None, -) -> Decimal: +def _component_total(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> Decimal: + total = Decimal("0") + for key, value in period.components.per_kwh.items(): + if any(marker in key for marker in markers): + total += value + return total + + +def _has_component(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> bool: + return any(any(marker in key for marker in markers) for key in period.components.per_kwh) + + +def rider18_export_rate_cents(period: SeasonalPeriodRate) -> Decimal: + return _component_total(period, GENERATION_COMPONENT_MARKERS) + _component_total( + period, + DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS, + ) + + +def rider18_export_formula_available(period: SeasonalPeriodRate) -> bool: + return _has_component(period, GENERATION_COMPONENT_MARKERS) and _has_component( + period, + DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS, + ) + + +def current_export_rate_cents(period: SeasonalPeriodRate, net_metering: bool) -> Decimal: if net_metering: return period.components.per_kwh_total - if rider18_export_cents is not None: - return rider18_export_cents - - generation_only = Decimal("0") - for key, value in period.components.per_kwh.items(): - if any(marker in key for marker in GENERATION_COMPONENT_MARKERS): - generation_only += value - return generation_only + return rider18_export_rate_cents(period) def period_display_name(period: SeasonalPeriodRate) -> str: diff --git a/custom_components/dte_rates/rider18_parser.py b/custom_components/dte_rates/rider18_parser.py deleted file mode 100644 index 47a8cfb..0000000 --- a/custom_components/dte_rates/rider18_parser.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -from decimal import Decimal, InvalidOperation -import posixpath -import re -from typing import Any -from xml.etree import ElementTree as ET -from zipfile import ZipFile -from io import BytesIO - - -_CELL_RE = re.compile(r"([A-Z]+)(\d+)") -_RATE_CODE_RE = re.compile(r"^[A-Z]\d+(?:\.\d+)?$") -_CREDIT_COLUMN = "D" - - -def parse_rider18_xlsx(xlsx_bytes: bytes) -> dict[str, dict[tuple[str, str], Decimal]]: - """Extract Rider 18 outflow credits as cents/kWh by rate, season, and period.""" - with ZipFile(BytesIO(xlsx_bytes)) as workbook: - shared_strings = _shared_strings(workbook) - sheet_path = _rates_sheet_path(workbook) - if sheet_path is None: - raise ValueError("Rider 18 workbook does not contain a Rates and Credits sheet") - - rows = _worksheet_rows(workbook, sheet_path, shared_strings) - - rates: dict[str, dict[tuple[str, str], Decimal]] = {} - current_rate_code: str | None = None - - for row_number in sorted(rows): - row = rows[row_number] - label = _clean_text(row.get("B")) - if not label: - continue - - if _RATE_CODE_RE.match(label): - current_rate_code = label - rates.setdefault(current_rate_code, {}) - continue - - if current_rate_code is None: - continue - - season_period = _season_period_from_label(label) - if season_period is None: - continue - - credit = _credit_cents(row.get(_CREDIT_COLUMN)) - if credit is None: - continue - - rates.setdefault(current_rate_code, {})[season_period] = credit - - return {code: values for code, values in rates.items() if values} - - -def _shared_strings(workbook: ZipFile) -> list[str]: - try: - root = ET.fromstring(workbook.read("xl/sharedStrings.xml")) - except KeyError: - return [] - - strings: list[str] = [] - for item in root.findall("{*}si"): - strings.append("".join(text.text or "" for text in item.findall(".//{*}t"))) - return strings - - -def _rates_sheet_path(workbook: ZipFile) -> str | None: - workbook_root = ET.fromstring(workbook.read("xl/workbook.xml")) - rels_root = ET.fromstring(workbook.read("xl/_rels/workbook.xml.rels")) - rel_targets = { - rel.attrib["Id"]: _normalize_target(rel.attrib.get("Target", "")) - for rel in rels_root.findall("{*}Relationship") - if "Id" in rel.attrib - } - - fallback: str | None = None - for sheet in workbook_root.findall(".//{*}sheet"): - name = sheet.attrib.get("name", "") - rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id") - target = rel_targets.get(rel_id or "") - if target is None: - continue - if name.strip().lower() == "rates and credits": - return target - if fallback is None and "rate" in name.lower(): - fallback = target - return fallback - - -def _normalize_target(target: str) -> str: - if target.startswith("/"): - return target.lstrip("/") - return posixpath.normpath(posixpath.join("xl", target)) - - -def _worksheet_rows( - workbook: ZipFile, - sheet_path: str, - shared_strings: list[str], -) -> dict[int, dict[str, str]]: - root = ET.fromstring(workbook.read(sheet_path)) - rows: dict[int, dict[str, str]] = {} - - for cell in root.findall(".//{*}c"): - ref = cell.attrib.get("r", "") - match = _CELL_RE.match(ref) - if not match: - continue - column, row_number_raw = match.groups() - value = _cell_value(cell, shared_strings) - if value is None: - continue - rows.setdefault(int(row_number_raw), {})[column] = value - - return rows - - -def _cell_value(cell: ET.Element, shared_strings: list[str]) -> str | None: - cell_type = cell.attrib.get("t") - if cell_type == "inlineStr": - return "".join(text.text or "" for text in cell.findall(".//{*}t")) - - value = cell.find("{*}v") - if value is None or value.text is None: - return None - - if cell_type == "s": - try: - return shared_strings[int(value.text)] - except (IndexError, ValueError): - return None - return value.text - - -def _clean_text(value: Any) -> str: - if value is None: - return "" - return " ".join(str(value).split()) - - -def _season_period_from_label(label: str) -> tuple[str, str] | None: - normalized = _clean_text(label).lower().replace("-", " ") - - if "june" in normalized or "summer" in normalized or "sept" in normalized: - season = "june_through_september" - elif "oct" in normalized or "winter" in normalized or "may" in normalized: - season = "october_through_may" - else: - return None - - if "super off peak" in normalized: - period = "super_off_peak" - elif "off peak" in normalized: - period = "off_peak" - elif "on peak" in normalized or "peak" in normalized: - period = "peak" - else: - return None - - return season, period - - -def _credit_cents(value: Any) -> Decimal | None: - text = _clean_text(value).replace("$", "").replace(",", "") - if not text: - return None - if text.startswith("(") and text.endswith(")"): - text = f"-{text[1:-1]}" - - try: - return (abs(Decimal(text)) * Decimal("100")).quantize(Decimal("0.001")) - except InvalidOperation: - return None diff --git a/custom_components/dte_rates/sensor.py b/custom_components/dte_rates/sensor.py index db53766..d34af13 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -1,8 +1,6 @@ from __future__ import annotations from collections import defaultdict -from decimal import Decimal - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry @@ -28,7 +26,6 @@ ATTR_RATE_CODE, ATTR_RATE_NAME, ATTR_RIDER18_EXPORT_AVAILABLE, - ATTR_RIDER18_SOURCE_URL, ATTR_SCHEDULE_BY_SEASON, ATTR_SCHEDULE_TEXT, ATTR_SEASON, @@ -46,6 +43,7 @@ get_active_period, get_next_rate_change, period_display_name, + rider18_export_formula_available, ) @@ -108,41 +106,24 @@ def _active_period(self) -> SeasonalPeriodRate | None: return None return get_active_period(rate, dt_util.now()) - def _rider18_export_rate_cents(self, period: SeasonalPeriodRate) -> Decimal | None: - if self._entry.data.get(CONF_NET_METERING, False): - return None - - rate = self._selected_rate() - if rate is None: - return None - - rider18_rates = getattr(self.coordinator.data, "rider18_export_rates", {}) - return rider18_rates.get(rate.code, {}).get((period.season_name, period.period_name)) - def _export_rate_cents(self, period: SeasonalPeriodRate): - return current_export_rate_cents( - period, - self._entry.data.get(CONF_NET_METERING, False), - self._rider18_export_rate_cents(period), - ) + return current_export_rate_cents(period, self._entry.data.get(CONF_NET_METERING, False)) def _export_rate_source(self, period: SeasonalPeriodRate) -> str: if self._entry.data.get(CONF_NET_METERING, False): return "net_metering" - if self._rider18_export_rate_cents(period) is not None: - return "rider18" - return "pdf_generation_components" + if rider18_export_formula_available(period): + return "rider18_formula" + return "rider18_formula_incomplete" def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None: if self._entry.data.get(CONF_NET_METERING, False): return None - if self._rider18_export_rate_cents(period) is not None: + if rider18_export_formula_available(period): return None - if not self.coordinator.data.rider18_source_url: - return "Rider 18 export credits are not loaded; using PDF generation-only export pricing." return ( - "No Rider 18 export credit matched the selected rate's active season and period; " - "using PDF generation-only export pricing." + "Rider 18 formula is missing a generation or distribution/transmission component " + "for the active period; using the available formula components only." ) def _warning(self) -> str | None: @@ -160,9 +141,6 @@ def _base_attributes(self) -> dict: ATTR_SOURCE_URL: self.coordinator.data.source_url, ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date, } - if self.coordinator.data.rider18_source_url: - attrs[ATTR_RIDER18_SOURCE_URL] = self.coordinator.data.rider18_source_url - rate = self._selected_rate() period = self._active_period() if rate is None or period is None: @@ -307,7 +285,7 @@ def extra_state_attributes(self) -> dict: period = self._active_period() if period is not None: attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period) - attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = self._rider18_export_rate_cents(period) is not None + attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period) warning = self._export_rate_warning(period) if warning is not None: attrs[ATTR_EXPORT_RATE_WARNING] = warning diff --git a/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md index 3ffd3b9..9610987 100644 --- a/docs/research/rider18-export-rates.md +++ b/docs/research/rider18-export-rates.md @@ -2,28 +2,27 @@ ## Source -- Enhancement request: use DTE Rider 18 calculator rates for export calculations. -- Workbook URL: `https://www.dteenergy.com/content/dam/dteenergy/deg/website/hybris/rooftop-solar/Rider18Calculator.xlsx` +- Enhancement request: calculate Rider 18 export credits from the rate card. +- User-provided formula reference: `Total per-kWh Credit = Generation Rate + Distribution/Transmission Rate`. -## Workbook Findings +## Implementation Decision -The workbook has a `Rates and Credits` sheet with Rider 18 credit values. The relevant table is labeled `FULL SERVICE - Rider 18 Credits`. +Do not use the Rider 18 calculator workbook for export pricing. The integration now derives non-net-metering Rider 18 export credits directly from the parsed residential rate card. -Parser validation on May 11, 2026 downloaded the workbook successfully and extracted D1.11 Rider 18 outflow credits from that sheet. +Formula: -Observed D1.11 rows: +```text +Rider 18 export credit = generation components + distribution/transmission components +``` -| Workbook label | Integration season | Integration period | Outflow credit incl. PSCR | -| --- | --- | --- | --- | -| `June-Sept On Peak` | `june_through_september` | `peak` | `$0.16284/kWh` | -| `June-Sept Off Peak` | `june_through_september` | `off_peak` | `$0.10586/kWh` | -| `Oct-May On Peak` | `october_through_may` | `peak` | `$0.12196/kWh` | -| `Oct-May Off Peak` | `october_through_may` | `off_peak` | `$0.10586/kWh` | +The parsed rate card currently identifies generation components by keys containing `capacity_energy` or `non_capacity_energy`. It identifies distribution/transmission components by keys containing `distribution` or `transmission`. -The workbook stores credits as negative dollars per kWh. The integration converts them to positive cents per kWh internally so they flow through the same calculator path as PDF-derived rates. +For net metering, keep the existing behavior: export uses the full active import rate from the selected rate plan. -## Implementation Decision +## UI/Entity Status -For non-net-metering export calculations, prefer the workbook `Outflow Cred. Incl. PSCR` column when a matching rate, season, and period exists. If Rider 18 data is unavailable or does not contain a matching period, fall back to the PDF generation-only calculation. +The setup flow explains that Rider 18 export credits are calculated from the parsed rate card formula. Export entities expose whether the active period has enough parsed components for the full formula: -For net metering, keep the existing behavior: export uses the full active import rate from the selected rate plan. +- `export_rate_source: rider18_formula` when generation and distribution/transmission components are present. +- `export_rate_source: rider18_formula_incomplete` when one side of the formula is missing. +- `export_rate_source: net_metering` when net metering is enabled. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 9494357..cba9306 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,6 +1,5 @@ from __future__ import annotations -from decimal import Decimal from unittest.mock import MagicMock import pytest @@ -45,7 +44,7 @@ async def _refresh(self): @pytest.mark.asyncio -async def test_config_flow_describes_rider18_loaded_status(monkeypatch): +async def test_config_flow_describes_rider18_formula_status(monkeypatch): flow = DteRatesConfigFlow() flow.hass = MagicMock() @@ -58,10 +57,6 @@ async def _refresh(self): "D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]), }, raw_text_hash="abc", - rider18_source_url="https://example.test/Rider18Calculator.xlsx", - rider18_export_rates={ - "D1.11": {("october_through_may", "off_peak"): Decimal("10.586")}, - }, ) monkeypatch.setattr( @@ -72,4 +67,7 @@ async def _refresh(self): result = await flow.async_step_user() assert result["type"] == "form" - assert result["description_placeholders"]["rider18_status"] == "Rider 18 export credits loaded for 1 rate plan." + assert ( + result["description_placeholders"]["rider18_status"] + == "Rider 18 export credits are calculated from the parsed rate card as Generation + Distribution/Transmission." + ) diff --git a/tests/test_rate_calculator.py b/tests/test_rate_calculator.py index d7ab4ae..62b5577 100644 --- a/tests/test_rate_calculator.py +++ b/tests/test_rate_calculator.py @@ -67,18 +67,29 @@ def test_import_is_total_of_all_per_kwh_components(): assert current_import_rate_cents(active) == Decimal("24.133") -def test_export_without_net_metering_only_generation(): +def test_rider18_export_without_net_metering_uses_generation_plus_distribution(): rate = _rate_plan() active = get_active_period(rate, datetime(2026, 6, 1, 16, 30)) assert active is not None - assert current_export_rate_cents(active, net_metering=False) == Decimal("14.407") + assert current_export_rate_cents(active, net_metering=False) == Decimal("24.133") -def test_export_without_net_metering_prefers_rider18_credit(): - rate = _rate_plan() - active = get_active_period(rate, datetime(2026, 6, 1, 16, 30)) - assert active is not None - assert current_export_rate_cents(active, net_metering=False, rider18_export_cents=Decimal("16.284")) == Decimal("16.284") +def test_rider18_export_excludes_unrelated_non_formula_components(): + period = SeasonalPeriodRate( + season_name="year_round", + period_name="all_kwh", + components=PriceComponents( + per_kwh={ + "capacity_energy": Decimal("1.000"), + "non_capacity_energy": Decimal("2.000"), + "distribution_kwh": Decimal("3.000"), + "misc_adjustment": Decimal("4.000"), + } + ), + window=TimeWindow(label="all_kwh"), + ) + + assert current_export_rate_cents(period, net_metering=False) == Decimal("6.000") def test_export_with_net_metering_uses_total(): diff --git a/tests/test_rider18_parser.py b/tests/test_rider18_parser.py deleted file mode 100644 index ca2fee6..0000000 --- a/tests/test_rider18_parser.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from decimal import Decimal -from html import escape -from io import BytesIO -from zipfile import ZipFile - -from custom_components.dte_rates.rider18_parser import parse_rider18_xlsx - - -def _xlsx_with_rates() -> bytes: - cells = { - "B2": "FULL SERVICE - Rider 18 Credits", - "G2": "FULL SERVICE - D1.11 Rate", - "B4": "PSCR (November 1, 2024)", - "C4": "0.01877", - "C6": "Rider 18 Tariff (No PSCR)", - "D6": "Outflow Cred. Incl. PSCR", - "B7": "D1.11", - "B8": "June-Sept On Peak", - "C8": "-0.14407", - "D8": "-0.16284000000000001", - "B9": "June-Sept Off Peak", - "D9": "-0.10586", - "B10": "Oct-May On Peak", - "D10": "-0.12196", - "B11": "Oct-May Off Peak", - "D11": "-0.10586", - } - rows: dict[int, list[str]] = {} - for ref, value in cells.items(): - row = int("".join(ch for ch in ref if ch.isdigit())) - if value.replace(".", "", 1).replace("-", "", 1).isdigit(): - cell = f'{value}' - else: - cell = f'{escape(value)}' - rows.setdefault(row, []).append(cell) - - sheet_data = "".join(f'{"".join(cells)}' for row, cells in sorted(rows.items())) - workbook_xml = ( - '' - "" - '' - '' - "" - "" - ) - rels_xml = ( - '' - '' - '' - "" - ) - sheet_xml = ( - '' - f"{sheet_data}" - "" - ) - - buffer = BytesIO() - with ZipFile(buffer, "w") as workbook: - workbook.writestr("xl/workbook.xml", workbook_xml) - workbook.writestr("xl/_rels/workbook.xml.rels", rels_xml) - workbook.writestr("xl/worksheets/sheet1.xml", "") - workbook.writestr("xl/worksheets/sheet3.xml", sheet_xml) - return buffer.getvalue() - - -def test_parse_rider18_xlsx_extracts_outflow_credits_in_cents(): - rates = parse_rider18_xlsx(_xlsx_with_rates()) - - assert rates == { - "D1.11": { - ("june_through_september", "peak"): Decimal("16.284"), - ("june_through_september", "off_peak"): Decimal("10.586"), - ("october_through_may", "peak"): Decimal("12.196"), - ("october_through_may", "off_peak"): Decimal("10.586"), - } - } diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9fded3f..28c36bb 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -17,9 +17,7 @@ ) -def _coordinator_with_rate( - rider18_export_rates: dict[tuple[str, str], Decimal] | None = None, -) -> SimpleNamespace: +def _coordinator_with_rate() -> SimpleNamespace: rate = RatePlan( code="D1.11", name="Standard Base", @@ -44,8 +42,6 @@ def _coordinator_with_rate( effective_date="February 6, 2025", rates={"D1.11": rate}, raw_text_hash="hash", - rider18_source_url="https://example.test/Rider18Calculator.xlsx" if rider18_export_rates else None, - rider18_export_rates={"D1.11": rider18_export_rates or {}}, ) ) @@ -64,34 +60,23 @@ def test_import_sensor_returns_total_rate(monkeypatch): assert sensor.extra_state_attributes["next_rate_change"] is None -def test_export_sensor_uses_generation_only_without_net_metering(monkeypatch): +def test_export_sensor_uses_rider18_formula_without_net_metering(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) coordinator = _coordinator_with_rate() entry = SimpleNamespace(entry_id="entry_2", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) sensor = DteExportRateSensor(coordinator, entry) - assert sensor.native_value == 0.03 + assert sensor.native_value == 0.06 assert sensor.extra_state_attributes["next_rate_value"] is None - - -def test_export_sensor_prefers_rider18_credit_without_net_metering(monkeypatch): - monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) - - coordinator = _coordinator_with_rate({("year_round", "all_kwh"): Decimal("10.586")}) - entry = SimpleNamespace(entry_id="entry_13", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) - - sensor = DteExportRateSensor(coordinator, entry) - assert sensor.native_value == 0.10586 - assert sensor.extra_state_attributes["rider18_source_url"] == "https://example.test/Rider18Calculator.xlsx" - assert sensor.extra_state_attributes["export_rate_source"] == "rider18" + assert sensor.extra_state_attributes["export_rate_source"] == "rider18_formula" assert sensor.extra_state_attributes["rider18_export_available"] is True def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) - coordinator = _coordinator_with_rate({("year_round", "all_kwh"): Decimal("10.586")}) + coordinator = _coordinator_with_rate() entry = SimpleNamespace(entry_id="entry_14", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: True}) sensor = DteExportRateSensor(coordinator, entry) @@ -99,19 +84,20 @@ def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): assert sensor.extra_state_attributes["export_rate_source"] == "net_metering" -def test_export_sensor_reports_generation_fallback_when_rider18_missing(monkeypatch): +def test_export_sensor_reports_formula_unavailable_without_distribution_or_transmission(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) - coordinator = _coordinator_with_rate({("june_through_september", "peak"): Decimal("16.284")}) + coordinator = _coordinator_with_rate() + coordinator.data.rates["D1.11"].periods[0].components.per_kwh.pop("distribution_kwh") entry = SimpleNamespace(entry_id="entry_16", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) sensor = DteExportRateSensor(coordinator, entry) attrs = sensor.extra_state_attributes assert sensor.native_value == 0.03 - assert attrs["export_rate_source"] == "pdf_generation_components" + assert attrs["export_rate_source"] == "rider18_formula_incomplete" assert attrs["rider18_export_available"] is False - assert "No Rider 18 export credit" in attrs["export_rate_warning"] + assert "distribution/transmission component" in attrs["export_rate_warning"] def test_sensor_warns_when_selected_rate_disappears(monkeypatch): @@ -172,21 +158,21 @@ def test_schedule_sensor_exposes_full_schedule(monkeypatch): assert sensor.native_value == "D1.11 (1 periods)" assert len(attrs["schedule_by_season"]) == 1 assert "Import $0.0600/kWh" in attrs["schedule_text"] - assert "Export $0.0300/kWh" in attrs["schedule_text"] - assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.03 + assert "Export $0.0600/kWh" in attrs["schedule_text"] + assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.06 assert attrs["next_rate_value"] is None -def test_schedule_sensor_uses_rider18_export_credit(monkeypatch): +def test_schedule_sensor_uses_rider18_formula(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) - coordinator = _coordinator_with_rate({("year_round", "all_kwh"): Decimal("10.586")}) + coordinator = _coordinator_with_rate() entry = SimpleNamespace(entry_id="entry_15", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) sensor = DteRateScheduleSensor(coordinator, entry) attrs = sensor.extra_state_attributes - assert "Export $0.1059/kWh" in attrs["schedule_text"] - assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.10586 + assert "Export $0.0600/kWh" in attrs["schedule_text"] + assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.06 def test_schedule_sensor_next_rate_value_defaults_to_import(monkeypatch): From 17169428f37dc62bf5b33f032eb37668ac351be5 Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Mon, 11 May 2026 10:35:50 -0400 Subject: [PATCH 4/6] Match Rider 18 export spreadsheet formula --- custom_components/dte_rates/__init__.py | 3 +- custom_components/dte_rates/config_flow.py | 2 +- custom_components/dte_rates/const.py | 6 + custom_components/dte_rates/coordinator.py | 33 ++++- custom_components/dte_rates/models.py | 2 + custom_components/dte_rates/pscr_parser.py | 124 ++++++++++++++++++ .../dte_rates/rate_calculator.py | 23 ++-- custom_components/dte_rates/sensor.py | 26 +++- docs/research/rider18-export-rates.md | 33 +++-- tests/test_config_flow.py | 3 +- tests/test_pscr_parser.py | 56 ++++++++ tests/test_rate_calculator.py | 7 +- tests/test_sensor.py | 22 ++-- 13 files changed, 296 insertions(+), 44 deletions(-) create mode 100644 custom_components/dte_rates/pscr_parser.py create mode 100644 tests/test_pscr_parser.py diff --git a/custom_components/dte_rates/__init__.py b/custom_components/dte_rates/__init__.py index 14557d0..1f283b8 100644 --- a/custom_components/dte_rates/__init__.py +++ b/custom_components/dte_rates/__init__.py @@ -122,9 +122,10 @@ async def _handle_show_schedule_service(call) -> None: lines_by_season: dict[str, list[str]] = defaultdict(list) net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False + pscr_cents = getattr(coordinator.data, "pscr_cents", None) for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)): import_usd = float(current_import_rate_cents(period) / 100) - export_usd = float(current_export_rate_cents(period, net_metering) / 100) + export_usd = float(current_export_rate_cents(period, net_metering, pscr_cents) / 100) lines_by_season[period.season_name].append( f"{period_display_name(period)}: Import ${import_usd:.4f}/kWh | Export ${export_usd:.4f}/kWh" ) diff --git a/custom_components/dte_rates/config_flow.py b/custom_components/dte_rates/config_flow.py index b03edfd..651285b 100644 --- a/custom_components/dte_rates/config_flow.py +++ b/custom_components/dte_rates/config_flow.py @@ -45,4 +45,4 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: def _rider18_status(_rate_card) -> str: - return "Rider 18 export credits are calculated from the parsed rate card as Generation + Distribution/Transmission." + return "Rider 18 export credits use parsed generation rates plus PSCR when the PSCR value is available." diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index aec2d9e..06cbd0f 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -6,6 +6,10 @@ "residential/Service-Request/pricing/residential-pricing-options/" "ResidentialElectricRateCard.pdf" ) +RIDER18_CALCULATOR_URL = ( + "https://www.dteenergy.com/content/dam/dteenergy/deg/website/" + "hybris/rooftop-solar/Rider18Calculator.xlsx" +) UPDATE_INTERVAL = timedelta(days=7) @@ -19,6 +23,8 @@ ATTR_COMPONENTS = "components" ATTR_MONTHLY_COMPONENTS = "monthly_components" ATTR_SOURCE_URL = "source_url" +ATTR_PSCR_CENTS = "pscr_cents" +ATTR_PSCR_SOURCE_URL = "pscr_source_url" ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available" ATTR_EXPORT_RATE_SOURCE = "export_rate_source" ATTR_EXPORT_RATE_WARNING = "export_rate_warning" diff --git a/custom_components/dte_rates/coordinator.py b/custom_components/dte_rates/coordinator.py index f7e83e2..8444103 100644 --- a/custom_components/dte_rates/coordinator.py +++ b/custom_components/dte_rates/coordinator.py @@ -7,9 +7,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import RATE_CARD_URL, UPDATE_INTERVAL +from .const import RATE_CARD_URL, RIDER18_CALCULATOR_URL, UPDATE_INTERVAL from .models import ParsedRateCard from .pdf_parser import parse_rate_card_pdf +from .pscr_parser import parse_pscr_cents_from_xlsx _LOGGER = logging.getLogger(__name__) @@ -33,11 +34,39 @@ async def _async_update_data(self) -> ParsedRateCard: except Exception as err: raise UpdateFailed(f"Failed downloading DTE rate card: {err}") from err + pscr_bytes: bytes | None = None try: - return await self.hass.async_add_executor_job( + async with session.get(RIDER18_CALCULATOR_URL, timeout=60) as resp: + resp.raise_for_status() + pscr_bytes = await resp.read() + except Exception as err: + _LOGGER.warning( + "Failed downloading DTE Rider 18 calculator; export rates will omit PSCR: %s", + err, + ) + + try: + parsed = await self.hass.async_add_executor_job( parse_rate_card_pdf, pdf_bytes, RATE_CARD_URL, ) except Exception as err: raise UpdateFailed(f"Failed parsing DTE rate card: {err}") from err + + if pscr_bytes is None: + return parsed + + try: + parsed.pscr_cents = await self.hass.async_add_executor_job( + parse_pscr_cents_from_xlsx, + pscr_bytes, + ) + parsed.pscr_source_url = RIDER18_CALCULATOR_URL + except Exception as err: + _LOGGER.warning( + "Failed parsing DTE Rider 18 PSCR; export rates will omit PSCR: %s", + err, + ) + + return parsed diff --git a/custom_components/dte_rates/models.py b/custom_components/dte_rates/models.py index 144716e..ace27e5 100644 --- a/custom_components/dte_rates/models.py +++ b/custom_components/dte_rates/models.py @@ -50,3 +50,5 @@ class ParsedRateCard: effective_date: str | None rates: dict[str, RatePlan] raw_text_hash: str + pscr_cents: Decimal | None = None + pscr_source_url: str | None = None diff --git a/custom_components/dte_rates/pscr_parser.py b/custom_components/dte_rates/pscr_parser.py new file mode 100644 index 0000000..8f26c44 --- /dev/null +++ b/custom_components/dte_rates/pscr_parser.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from io import BytesIO +import posixpath +import re +from typing import Any +from xml.etree import ElementTree as ET +from zipfile import ZipFile + + +_CELL_RE = re.compile(r"([A-Z]+)(\d+)") + + +def parse_pscr_cents_from_xlsx(xlsx_bytes: bytes) -> Decimal: + """Extract the Rider 18 PSCR value as cents/kWh.""" + with ZipFile(BytesIO(xlsx_bytes)) as workbook: + shared_strings = _shared_strings(workbook) + sheet_path = _rates_sheet_path(workbook) + rows = _worksheet_rows(workbook, sheet_path, shared_strings) + + for row_number in sorted(rows): + row = rows[row_number] + label = " ".join(row.values()).lower() + if "pscr" not in label: + continue + + for column in ("C", "D", "B", "E"): + cents = _dollars_to_cents(row.get(column)) + if cents is not None: + return cents + + raise ValueError("Rider 18 workbook does not contain a PSCR value") + + +def _shared_strings(workbook: ZipFile) -> list[str]: + try: + root = ET.fromstring(workbook.read("xl/sharedStrings.xml")) + except KeyError: + return [] + + return ["".join(text.text or "" for text in item.findall(".//{*}t")) for item in root.findall("{*}si")] + + +def _rates_sheet_path(workbook: ZipFile) -> str: + workbook_root = ET.fromstring(workbook.read("xl/workbook.xml")) + rels_root = ET.fromstring(workbook.read("xl/_rels/workbook.xml.rels")) + rel_targets = { + rel.attrib["Id"]: _normalize_target(rel.attrib.get("Target", "")) + for rel in rels_root.findall("{*}Relationship") + if "Id" in rel.attrib + } + + fallback: str | None = None + for sheet in workbook_root.findall(".//{*}sheet"): + name = sheet.attrib.get("name", "") + rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id") + target = rel_targets.get(rel_id or "") + if target is None: + continue + if name.strip().lower() == "rates and credits": + return target + if fallback is None and "rate" in name.lower(): + fallback = target + + if fallback is None: + raise ValueError("Rider 18 workbook does not contain a rates sheet") + return fallback + + +def _normalize_target(target: str) -> str: + if target.startswith("/"): + return target.lstrip("/") + return posixpath.normpath(posixpath.join("xl", target)) + + +def _worksheet_rows( + workbook: ZipFile, + sheet_path: str, + shared_strings: list[str], +) -> dict[int, dict[str, str]]: + root = ET.fromstring(workbook.read(sheet_path)) + rows: dict[int, dict[str, str]] = {} + + for cell in root.findall(".//{*}c"): + ref = cell.attrib.get("r", "") + match = _CELL_RE.match(ref) + if not match: + continue + + column, row_number_raw = match.groups() + value = _cell_value(cell, shared_strings) + if value is not None: + rows.setdefault(int(row_number_raw), {})[column] = " ".join(value.split()) + + return rows + + +def _cell_value(cell: ET.Element, shared_strings: list[str]) -> str | None: + cell_type = cell.attrib.get("t") + if cell_type == "inlineStr": + return "".join(text.text or "" for text in cell.findall(".//{*}t")) + + value = cell.find("{*}v") + if value is None or value.text is None: + return None + + if cell_type == "s": + try: + return shared_strings[int(value.text)] + except (IndexError, ValueError): + return None + return value.text + + +def _dollars_to_cents(value: Any) -> Decimal | None: + text = str(value or "").replace("$", "").replace(",", "").strip() + if not text: + return None + + try: + return (abs(Decimal(text)) * Decimal("100")).quantize(Decimal("0.001")) + except InvalidOperation: + return None diff --git a/custom_components/dte_rates/rate_calculator.py b/custom_components/dte_rates/rate_calculator.py index a61a552..aaeb9a3 100644 --- a/custom_components/dte_rates/rate_calculator.py +++ b/custom_components/dte_rates/rate_calculator.py @@ -7,7 +7,6 @@ GENERATION_COMPONENT_MARKERS = ("capacity_energy", "non_capacity_energy") -DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS = ("distribution", "transmission") def _is_hour_in_range(start_hour: int, end_hour: int, hour: int) -> bool: @@ -78,25 +77,23 @@ def _has_component(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> bool return any(any(marker in key for marker in markers) for key in period.components.per_kwh) -def rider18_export_rate_cents(period: SeasonalPeriodRate) -> Decimal: - return _component_total(period, GENERATION_COMPONENT_MARKERS) + _component_total( - period, - DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS, - ) +def rider18_export_rate_cents(period: SeasonalPeriodRate, pscr_cents: Decimal | None = None) -> Decimal: + return _component_total(period, GENERATION_COMPONENT_MARKERS) + (pscr_cents or Decimal("0")) -def rider18_export_formula_available(period: SeasonalPeriodRate) -> bool: - return _has_component(period, GENERATION_COMPONENT_MARKERS) and _has_component( - period, - DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS, - ) +def rider18_export_formula_available(period: SeasonalPeriodRate, pscr_cents: Decimal | None = None) -> bool: + return _has_component(period, GENERATION_COMPONENT_MARKERS) and pscr_cents is not None -def current_export_rate_cents(period: SeasonalPeriodRate, net_metering: bool) -> Decimal: +def current_export_rate_cents( + period: SeasonalPeriodRate, + net_metering: bool, + pscr_cents: Decimal | None = None, +) -> Decimal: if net_metering: return period.components.per_kwh_total - return rider18_export_rate_cents(period) + return rider18_export_rate_cents(period, pscr_cents) def period_display_name(period: SeasonalPeriodRate) -> str: diff --git a/custom_components/dte_rates/sensor.py b/custom_components/dte_rates/sensor.py index d34af13..68394c4 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -1,6 +1,8 @@ from __future__ import annotations from collections import defaultdict +from decimal import Decimal + from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry @@ -23,6 +25,8 @@ ATTR_NEXT_RATE_CHANGE, ATTR_NEXT_RATE_NAME, ATTR_NEXT_RATE_VALUE, + ATTR_PSCR_CENTS, + ATTR_PSCR_SOURCE_URL, ATTR_RATE_CODE, ATTR_RATE_NAME, ATTR_RIDER18_EXPORT_AVAILABLE, @@ -106,23 +110,30 @@ def _active_period(self) -> SeasonalPeriodRate | None: return None return get_active_period(rate, dt_util.now()) + def _pscr_cents(self) -> Decimal | None: + return getattr(self.coordinator.data, "pscr_cents", None) + def _export_rate_cents(self, period: SeasonalPeriodRate): - return current_export_rate_cents(period, self._entry.data.get(CONF_NET_METERING, False)) + return current_export_rate_cents( + period, + self._entry.data.get(CONF_NET_METERING, False), + self._pscr_cents(), + ) def _export_rate_source(self, period: SeasonalPeriodRate) -> str: if self._entry.data.get(CONF_NET_METERING, False): return "net_metering" - if rider18_export_formula_available(period): + if rider18_export_formula_available(period, self._pscr_cents()): return "rider18_formula" return "rider18_formula_incomplete" def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None: if self._entry.data.get(CONF_NET_METERING, False): return None - if rider18_export_formula_available(period): + if rider18_export_formula_available(period, self._pscr_cents()): return None return ( - "Rider 18 formula is missing a generation or distribution/transmission component " + "Rider 18 formula is missing a generation component or PSCR value " "for the active period; using the available formula components only." ) @@ -141,6 +152,11 @@ def _base_attributes(self) -> dict: ATTR_SOURCE_URL: self.coordinator.data.source_url, ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date, } + if self.coordinator.data.pscr_cents is not None: + attrs[ATTR_PSCR_CENTS] = float(self.coordinator.data.pscr_cents) + if self.coordinator.data.pscr_source_url: + attrs[ATTR_PSCR_SOURCE_URL] = self.coordinator.data.pscr_source_url + rate = self._selected_rate() period = self._active_period() if rate is None or period is None: @@ -285,7 +301,7 @@ def extra_state_attributes(self) -> dict: period = self._active_period() if period is not None: attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period) - attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period) + attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents()) warning = self._export_rate_warning(period) if warning is not None: attrs[ATTR_EXPORT_RATE_WARNING] = warning diff --git a/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md index 9610987..0370e25 100644 --- a/docs/research/rider18-export-rates.md +++ b/docs/research/rider18-export-rates.md @@ -1,28 +1,45 @@ # Rider 18 Export Rates -## Source +## Sources -- Enhancement request: calculate Rider 18 export credits from the rate card. -- User-provided formula reference: `Total per-kWh Credit = Generation Rate + Distribution/Transmission Rate`. +- Enhancement request: calculate Rider 18 export credits to match DTE's Rider 18 calculator. +- Residential rate card: source of the active generation components for each rate period. +- Rider 18 calculator workbook: source of the current PSCR scalar. ## Implementation Decision -Do not use the Rider 18 calculator workbook for export pricing. The integration now derives non-net-metering Rider 18 export credits directly from the parsed residential rate card. +Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. The workbook is used only to extract the current PSCR value, because the residential rate card PDF does not include that scalar. Formula: ```text -Rider 18 export credit = generation components + distribution/transmission components +Rider 18 export credit = generation components + PSCR ``` -The parsed rate card currently identifies generation components by keys containing `capacity_energy` or `non_capacity_energy`. It identifies distribution/transmission components by keys containing `distribution` or `transmission`. +The parsed rate card identifies generation components by keys containing `capacity_energy` or `non_capacity_energy`. + +Validation against the live D1.11 workbook on May 11, 2026 showed that DTE's workbook calculates: + +```text +Rider 18 Tariff (No PSCR) = Capacity Charges + Non-Capacity Charges +Outflow Credit Incl. PSCR = Rider 18 Tariff (No PSCR) + PSCR +``` + +D1.11 examples: + +| Period | Generation | PSCR | Expected export credit | +| --- | ---: | ---: | ---: | +| Summer On-Peak | `14.407¢/kWh` | `1.877¢/kWh` | `16.284¢/kWh` | +| Summer Off-Peak | `8.709¢/kWh` | `1.877¢/kWh` | `10.586¢/kWh` | +| Winter On-Peak | `10.319¢/kWh` | `1.877¢/kWh` | `12.196¢/kWh` | +| Winter Off-Peak | `8.709¢/kWh` | `1.877¢/kWh` | `10.586¢/kWh` | For net metering, keep the existing behavior: export uses the full active import rate from the selected rate plan. ## UI/Entity Status -The setup flow explains that Rider 18 export credits are calculated from the parsed rate card formula. Export entities expose whether the active period has enough parsed components for the full formula: +The setup flow explains that Rider 18 export credits use parsed generation plus PSCR when the PSCR value is available. Export entities expose whether the active period has enough data for the full formula: -- `export_rate_source: rider18_formula` when generation and distribution/transmission components are present. +- `export_rate_source: rider18_formula` when generation components and PSCR are present. - `export_rate_source: rider18_formula_incomplete` when one side of the formula is missing. - `export_rate_source: net_metering` when net metering is enabled. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index cba9306..7754bbf 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -23,6 +23,7 @@ async def _refresh(self): "D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]), }, raw_text_hash="abc", + pscr_cents=None, ) monkeypatch.setattr( @@ -69,5 +70,5 @@ async def _refresh(self): assert result["type"] == "form" assert ( result["description_placeholders"]["rider18_status"] - == "Rider 18 export credits are calculated from the parsed rate card as Generation + Distribution/Transmission." + == "Rider 18 export credits use parsed generation rates plus PSCR when the PSCR value is available." ) diff --git a/tests/test_pscr_parser.py b/tests/test_pscr_parser.py new file mode 100644 index 0000000..c604ea7 --- /dev/null +++ b/tests/test_pscr_parser.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from decimal import Decimal +from html import escape +from io import BytesIO +from zipfile import ZipFile + +from custom_components.dte_rates.pscr_parser import parse_pscr_cents_from_xlsx + + +def _xlsx_with_pscr() -> bytes: + cells = { + "B2": "FULL SERVICE - Rider 18 Credits", + "B4": "PSCR (November 1, 2024)", + "C4": "0.01877", + } + rows: dict[int, list[str]] = {} + for ref, value in cells.items(): + row = int("".join(ch for ch in ref if ch.isdigit())) + if value.replace(".", "", 1).isdigit(): + cell = f'{value}' + else: + cell = f'{escape(value)}' + rows.setdefault(row, []).append(cell) + + sheet_data = "".join(f'{"".join(cells)}' for row, cells in sorted(rows.items())) + workbook_xml = ( + '' + "" + '' + "" + "" + ) + rels_xml = ( + '' + '' + "" + ) + sheet_xml = ( + '' + f"{sheet_data}" + "" + ) + + buffer = BytesIO() + with ZipFile(buffer, "w") as workbook: + workbook.writestr("xl/workbook.xml", workbook_xml) + workbook.writestr("xl/_rels/workbook.xml.rels", rels_xml) + workbook.writestr("xl/worksheets/sheet1.xml", sheet_xml) + return buffer.getvalue() + + +def test_parse_pscr_cents_from_xlsx(): + assert parse_pscr_cents_from_xlsx(_xlsx_with_pscr()) == Decimal("1.877") diff --git a/tests/test_rate_calculator.py b/tests/test_rate_calculator.py index 62b5577..98eae68 100644 --- a/tests/test_rate_calculator.py +++ b/tests/test_rate_calculator.py @@ -67,11 +67,11 @@ def test_import_is_total_of_all_per_kwh_components(): assert current_import_rate_cents(active) == Decimal("24.133") -def test_rider18_export_without_net_metering_uses_generation_plus_distribution(): +def test_rider18_export_without_net_metering_matches_spreadsheet_generation_plus_pscr(): rate = _rate_plan() active = get_active_period(rate, datetime(2026, 6, 1, 16, 30)) assert active is not None - assert current_export_rate_cents(active, net_metering=False) == Decimal("24.133") + assert current_export_rate_cents(active, net_metering=False, pscr_cents=Decimal("1.877")) == Decimal("16.284") def test_rider18_export_excludes_unrelated_non_formula_components(): @@ -82,14 +82,13 @@ def test_rider18_export_excludes_unrelated_non_formula_components(): per_kwh={ "capacity_energy": Decimal("1.000"), "non_capacity_energy": Decimal("2.000"), - "distribution_kwh": Decimal("3.000"), "misc_adjustment": Decimal("4.000"), } ), window=TimeWindow(label="all_kwh"), ) - assert current_export_rate_cents(period, net_metering=False) == Decimal("6.000") + assert current_export_rate_cents(period, net_metering=False, pscr_cents=Decimal("1.877")) == Decimal("4.877") def test_export_with_net_metering_uses_total(): diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 28c36bb..deb93da 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -42,6 +42,8 @@ def _coordinator_with_rate() -> SimpleNamespace: effective_date="February 6, 2025", rates={"D1.11": rate}, raw_text_hash="hash", + pscr_cents=Decimal("1.877"), + pscr_source_url="https://example.test/Rider18Calculator.xlsx", ) ) @@ -67,10 +69,11 @@ def test_export_sensor_uses_rider18_formula_without_net_metering(monkeypatch): entry = SimpleNamespace(entry_id="entry_2", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) sensor = DteExportRateSensor(coordinator, entry) - assert sensor.native_value == 0.06 + assert sensor.native_value == 0.04877 assert sensor.extra_state_attributes["next_rate_value"] is None assert sensor.extra_state_attributes["export_rate_source"] == "rider18_formula" assert sensor.extra_state_attributes["rider18_export_available"] is True + assert sensor.extra_state_attributes["pscr_cents"] == 1.877 def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): @@ -84,11 +87,12 @@ def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): assert sensor.extra_state_attributes["export_rate_source"] == "net_metering" -def test_export_sensor_reports_formula_unavailable_without_distribution_or_transmission(monkeypatch): +def test_export_sensor_reports_formula_unavailable_without_pscr(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) coordinator = _coordinator_with_rate() - coordinator.data.rates["D1.11"].periods[0].components.per_kwh.pop("distribution_kwh") + coordinator.data.pscr_cents = None + coordinator.data.pscr_source_url = None entry = SimpleNamespace(entry_id="entry_16", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) sensor = DteExportRateSensor(coordinator, entry) @@ -97,7 +101,7 @@ def test_export_sensor_reports_formula_unavailable_without_distribution_or_trans assert sensor.native_value == 0.03 assert attrs["export_rate_source"] == "rider18_formula_incomplete" assert attrs["rider18_export_available"] is False - assert "distribution/transmission component" in attrs["export_rate_warning"] + assert "PSCR" in attrs["export_rate_warning"] def test_sensor_warns_when_selected_rate_disappears(monkeypatch): @@ -134,7 +138,7 @@ def test_attributes_include_next_rate_metadata(monkeypatch): assert attrs["next_rate_change"] == "2026-03-01T15:00:00" assert attrs["next_rate_name"] == "Summer On-Peak" - assert attrs["next_rate_value"] == 0.04 + assert attrs["next_rate_value"] == 0.05877 def test_current_rate_name_sensor(monkeypatch): @@ -158,8 +162,8 @@ def test_schedule_sensor_exposes_full_schedule(monkeypatch): assert sensor.native_value == "D1.11 (1 periods)" assert len(attrs["schedule_by_season"]) == 1 assert "Import $0.0600/kWh" in attrs["schedule_text"] - assert "Export $0.0600/kWh" in attrs["schedule_text"] - assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.06 + assert "Export $0.0488/kWh" in attrs["schedule_text"] + assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.04877 assert attrs["next_rate_value"] is None @@ -171,8 +175,8 @@ def test_schedule_sensor_uses_rider18_formula(monkeypatch): sensor = DteRateScheduleSensor(coordinator, entry) attrs = sensor.extra_state_attributes - assert "Export $0.0600/kWh" in attrs["schedule_text"] - assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.06 + assert "Export $0.0488/kWh" in attrs["schedule_text"] + assert attrs["schedule_by_season"][0]["export_usd_per_kwh"] == 0.04877 def test_schedule_sensor_next_rate_value_defaults_to_import(monkeypatch): From ab13780e10fbfb567cee0fb539ab00243109c354 Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Mon, 11 May 2026 10:40:18 -0400 Subject: [PATCH 5/6] Source PSCR from MPSC rate book --- custom_components/dte_rates/const.py | 6 +- custom_components/dte_rates/coordinator.py | 21 ++-- custom_components/dte_rates/pscr_parser.py | 130 +++------------------ docs/research/rider18-export-rates.md | 4 +- tests/test_pscr_parser.py | 79 +++++-------- tests/test_sensor.py | 2 +- 6 files changed, 62 insertions(+), 180 deletions(-) diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index 06cbd0f..8ee39aa 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -6,9 +6,9 @@ "residential/Service-Request/pricing/residential-pricing-options/" "ResidentialElectricRateCard.pdf" ) -RIDER18_CALCULATOR_URL = ( - "https://www.dteenergy.com/content/dam/dteenergy/deg/website/" - "hybris/rooftop-solar/Rider18Calculator.xlsx" +PSCR_RATE_BOOK_URL = ( + "https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/" + "rate-books/electric/dte/dtee1cur.pdf" ) UPDATE_INTERVAL = timedelta(days=7) diff --git a/custom_components/dte_rates/coordinator.py b/custom_components/dte_rates/coordinator.py index 8444103..72973db 100644 --- a/custom_components/dte_rates/coordinator.py +++ b/custom_components/dte_rates/coordinator.py @@ -7,10 +7,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import RATE_CARD_URL, RIDER18_CALCULATOR_URL, UPDATE_INTERVAL +from .const import PSCR_RATE_BOOK_URL, RATE_CARD_URL, UPDATE_INTERVAL from .models import ParsedRateCard from .pdf_parser import parse_rate_card_pdf -from .pscr_parser import parse_pscr_cents_from_xlsx +from .pscr_parser import parse_pscr_cents_from_pdf _LOGGER = logging.getLogger(__name__) @@ -36,12 +36,19 @@ async def _async_update_data(self) -> ParsedRateCard: pscr_bytes: bytes | None = None try: - async with session.get(RIDER18_CALCULATOR_URL, timeout=60) as resp: + async with session.get( + PSCR_RATE_BOOK_URL, + headers={ + "Accept": "application/pdf,*/*", + "User-Agent": "DTE-Rates-for-Home-Assistant/1.0", + }, + timeout=60, + ) as resp: resp.raise_for_status() pscr_bytes = await resp.read() except Exception as err: _LOGGER.warning( - "Failed downloading DTE Rider 18 calculator; export rates will omit PSCR: %s", + "Failed downloading MPSC DTE rate book; export rates will omit PSCR: %s", err, ) @@ -59,13 +66,13 @@ async def _async_update_data(self) -> ParsedRateCard: try: parsed.pscr_cents = await self.hass.async_add_executor_job( - parse_pscr_cents_from_xlsx, + parse_pscr_cents_from_pdf, pscr_bytes, ) - parsed.pscr_source_url = RIDER18_CALCULATOR_URL + parsed.pscr_source_url = PSCR_RATE_BOOK_URL except Exception as err: _LOGGER.warning( - "Failed parsing DTE Rider 18 PSCR; export rates will omit PSCR: %s", + "Failed parsing MPSC DTE rate book PSCR; export rates will omit PSCR: %s", err, ) diff --git a/custom_components/dte_rates/pscr_parser.py b/custom_components/dte_rates/pscr_parser.py index 8f26c44..c0707be 100644 --- a/custom_components/dte_rates/pscr_parser.py +++ b/custom_components/dte_rates/pscr_parser.py @@ -1,124 +1,26 @@ from __future__ import annotations -from decimal import Decimal, InvalidOperation -from io import BytesIO -import posixpath +from decimal import Decimal +import io import re -from typing import Any -from xml.etree import ElementTree as ET -from zipfile import ZipFile +from pypdf import PdfReader -_CELL_RE = re.compile(r"([A-Z]+)(\d+)") +_D111_PSCR_RE = re.compile( + r"\bD1\.11\s+Standard\s+TOU\s+(?P\d+\.\d+)", + re.IGNORECASE, +) -def parse_pscr_cents_from_xlsx(xlsx_bytes: bytes) -> Decimal: - """Extract the Rider 18 PSCR value as cents/kWh.""" - with ZipFile(BytesIO(xlsx_bytes)) as workbook: - shared_strings = _shared_strings(workbook) - sheet_path = _rates_sheet_path(workbook) - rows = _worksheet_rows(workbook, sheet_path, shared_strings) - for row_number in sorted(rows): - row = rows[row_number] - label = " ".join(row.values()).lower() - if "pscr" not in label: - continue +def parse_pscr_cents_from_pdf(pdf_bytes: bytes) -> Decimal: + """Extract the current PSCR value from the MPSC DTE electric rate book.""" + reader = PdfReader(io.BytesIO(pdf_bytes)) + text = "\n".join(page.extract_text() or "" for page in reader.pages) + normalized = re.sub(r"\s+", " ", text) - for column in ("C", "D", "B", "E"): - cents = _dollars_to_cents(row.get(column)) - if cents is not None: - return cents + match = _D111_PSCR_RE.search(normalized) + if match: + return Decimal(match.group("pscr")) - raise ValueError("Rider 18 workbook does not contain a PSCR value") - - -def _shared_strings(workbook: ZipFile) -> list[str]: - try: - root = ET.fromstring(workbook.read("xl/sharedStrings.xml")) - except KeyError: - return [] - - return ["".join(text.text or "" for text in item.findall(".//{*}t")) for item in root.findall("{*}si")] - - -def _rates_sheet_path(workbook: ZipFile) -> str: - workbook_root = ET.fromstring(workbook.read("xl/workbook.xml")) - rels_root = ET.fromstring(workbook.read("xl/_rels/workbook.xml.rels")) - rel_targets = { - rel.attrib["Id"]: _normalize_target(rel.attrib.get("Target", "")) - for rel in rels_root.findall("{*}Relationship") - if "Id" in rel.attrib - } - - fallback: str | None = None - for sheet in workbook_root.findall(".//{*}sheet"): - name = sheet.attrib.get("name", "") - rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id") - target = rel_targets.get(rel_id or "") - if target is None: - continue - if name.strip().lower() == "rates and credits": - return target - if fallback is None and "rate" in name.lower(): - fallback = target - - if fallback is None: - raise ValueError("Rider 18 workbook does not contain a rates sheet") - return fallback - - -def _normalize_target(target: str) -> str: - if target.startswith("/"): - return target.lstrip("/") - return posixpath.normpath(posixpath.join("xl", target)) - - -def _worksheet_rows( - workbook: ZipFile, - sheet_path: str, - shared_strings: list[str], -) -> dict[int, dict[str, str]]: - root = ET.fromstring(workbook.read(sheet_path)) - rows: dict[int, dict[str, str]] = {} - - for cell in root.findall(".//{*}c"): - ref = cell.attrib.get("r", "") - match = _CELL_RE.match(ref) - if not match: - continue - - column, row_number_raw = match.groups() - value = _cell_value(cell, shared_strings) - if value is not None: - rows.setdefault(int(row_number_raw), {})[column] = " ".join(value.split()) - - return rows - - -def _cell_value(cell: ET.Element, shared_strings: list[str]) -> str | None: - cell_type = cell.attrib.get("t") - if cell_type == "inlineStr": - return "".join(text.text or "" for text in cell.findall(".//{*}t")) - - value = cell.find("{*}v") - if value is None or value.text is None: - return None - - if cell_type == "s": - try: - return shared_strings[int(value.text)] - except (IndexError, ValueError): - return None - return value.text - - -def _dollars_to_cents(value: Any) -> Decimal | None: - text = str(value or "").replace("$", "").replace(",", "").strip() - if not text: - return None - - try: - return (abs(Decimal(text)) * Decimal("100")).quantize(Decimal("0.001")) - except InvalidOperation: - return None + raise ValueError("MPSC DTE rate book does not contain a D1.11 PSCR value") diff --git a/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md index 0370e25..8fe1e24 100644 --- a/docs/research/rider18-export-rates.md +++ b/docs/research/rider18-export-rates.md @@ -4,11 +4,11 @@ - Enhancement request: calculate Rider 18 export credits to match DTE's Rider 18 calculator. - Residential rate card: source of the active generation components for each rate period. -- Rider 18 calculator workbook: source of the current PSCR scalar. +- MPSC DTE electric rate book: source of the current PSCR scalar. ## Implementation Decision -Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. The workbook is used only to extract the current PSCR value, because the residential rate card PDF does not include that scalar. +Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. PSCR is parsed dynamically from the MPSC DTE electric rate book PDF at `https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf`, because the residential rate card PDF does not include that scalar. Formula: diff --git a/tests/test_pscr_parser.py b/tests/test_pscr_parser.py index c604ea7..49b78a7 100644 --- a/tests/test_pscr_parser.py +++ b/tests/test_pscr_parser.py @@ -1,56 +1,29 @@ from __future__ import annotations from decimal import Decimal -from html import escape -from io import BytesIO -from zipfile import ZipFile - -from custom_components.dte_rates.pscr_parser import parse_pscr_cents_from_xlsx - - -def _xlsx_with_pscr() -> bytes: - cells = { - "B2": "FULL SERVICE - Rider 18 Credits", - "B4": "PSCR (November 1, 2024)", - "C4": "0.01877", - } - rows: dict[int, list[str]] = {} - for ref, value in cells.items(): - row = int("".join(ch for ch in ref if ch.isdigit())) - if value.replace(".", "", 1).isdigit(): - cell = f'{value}' - else: - cell = f'{escape(value)}' - rows.setdefault(row, []).append(cell) - - sheet_data = "".join(f'{"".join(cells)}' for row, cells in sorted(rows.items())) - workbook_xml = ( - '' - "" - '' - "" - "" - ) - rels_xml = ( - '' - '' - "" - ) - sheet_xml = ( - '' - f"{sheet_data}" - "" - ) - - buffer = BytesIO() - with ZipFile(buffer, "w") as workbook: - workbook.writestr("xl/workbook.xml", workbook_xml) - workbook.writestr("xl/_rels/workbook.xml.rels", rels_xml) - workbook.writestr("xl/worksheets/sheet1.xml", sheet_xml) - return buffer.getvalue() - - -def test_parse_pscr_cents_from_xlsx(): - assert parse_pscr_cents_from_xlsx(_xlsx_with_pscr()) == Decimal("1.877") + +from custom_components.dte_rates.pscr_parser import parse_pscr_cents_from_pdf + + +SAMPLE_RATE_BOOK_TEXT = """ +C8.5 SURCHARGES AND CREDITS APPLICABLE TO POWER SUPPLY SERVICE +Rate Schedule Description PSCR Factor Other Charges +D1.11 Standard TOU 1.877 0.0221 +D1.13 Overnight Savers 1.877 0.0221 +""" + + +class _FakePage: + def extract_text(self): + return SAMPLE_RATE_BOOK_TEXT + + +class _FakeReader: + def __init__(self, _bytes): + self.pages = [_FakePage()] + + +def test_parse_pscr_cents_from_mpsc_rate_book_pdf(monkeypatch): + monkeypatch.setattr("custom_components.dte_rates.pscr_parser.PdfReader", _FakeReader) + + assert parse_pscr_cents_from_pdf(b"fake") == Decimal("1.877") diff --git a/tests/test_sensor.py b/tests/test_sensor.py index deb93da..934696b 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -43,7 +43,7 @@ def _coordinator_with_rate() -> SimpleNamespace: rates={"D1.11": rate}, raw_text_hash="hash", pscr_cents=Decimal("1.877"), - pscr_source_url="https://example.test/Rider18Calculator.xlsx", + pscr_source_url="https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf", ) ) From d0da093bb63e9054fb07fe187e8bf9e6c9e71c86 Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Mon, 11 May 2026 11:42:53 -0400 Subject: [PATCH 6/6] Use tariff-specific MPSC PSCR factors --- custom_components/dte_rates/__init__.py | 2 +- custom_components/dte_rates/config_flow.py | 8 +++- custom_components/dte_rates/const.py | 2 + custom_components/dte_rates/coordinator.py | 6 +-- custom_components/dte_rates/models.py | 2 +- custom_components/dte_rates/pscr_parser.py | 48 +++++++++++++++++----- custom_components/dte_rates/sensor.py | 27 ++++++++---- docs/research/rider18-export-rates.md | 8 ++-- tests/test_config_flow.py | 9 +++- tests/test_pscr_parser.py | 20 +++++++-- tests/test_sensor.py | 5 ++- 11 files changed, 100 insertions(+), 37 deletions(-) diff --git a/custom_components/dte_rates/__init__.py b/custom_components/dte_rates/__init__.py index 1f283b8..20ea0f2 100644 --- a/custom_components/dte_rates/__init__.py +++ b/custom_components/dte_rates/__init__.py @@ -122,7 +122,7 @@ async def _handle_show_schedule_service(call) -> None: lines_by_season: dict[str, list[str]] = defaultdict(list) net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False - pscr_cents = getattr(coordinator.data, "pscr_cents", None) + pscr_cents = getattr(coordinator.data, "pscr_rates", {}).get(rate.code) for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)): import_usd = float(current_import_rate_cents(period) / 100) export_usd = float(current_export_rate_cents(period, net_metering, pscr_cents) / 100) diff --git a/custom_components/dte_rates/config_flow.py b/custom_components/dte_rates/config_flow.py index 651285b..5b11210 100644 --- a/custom_components/dte_rates/config_flow.py +++ b/custom_components/dte_rates/config_flow.py @@ -44,5 +44,9 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: ) -def _rider18_status(_rate_card) -> str: - return "Rider 18 export credits use parsed generation rates plus PSCR when the PSCR value is available." +def _rider18_status(rate_card) -> str: + count = len(getattr(rate_card, "pscr_rates", {})) + if count: + suffix = "tariff" if count == 1 else "tariffs" + return f"Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for {count} {suffix}." + return "Rider 18 export credits use parsed generation rates plus MPSC PSCR when the selected tariff has a PSCR factor." diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index 8ee39aa..a42cd55 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -24,6 +24,8 @@ ATTR_MONTHLY_COMPONENTS = "monthly_components" ATTR_SOURCE_URL = "source_url" ATTR_PSCR_CENTS = "pscr_cents" +ATTR_PSCR_RATE_CODE = "pscr_rate_code" +ATTR_PSCR_RATES = "pscr_rates" ATTR_PSCR_SOURCE_URL = "pscr_source_url" ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available" ATTR_EXPORT_RATE_SOURCE = "export_rate_source" diff --git a/custom_components/dte_rates/coordinator.py b/custom_components/dte_rates/coordinator.py index 72973db..93bd702 100644 --- a/custom_components/dte_rates/coordinator.py +++ b/custom_components/dte_rates/coordinator.py @@ -10,7 +10,7 @@ from .const import PSCR_RATE_BOOK_URL, RATE_CARD_URL, UPDATE_INTERVAL from .models import ParsedRateCard from .pdf_parser import parse_rate_card_pdf -from .pscr_parser import parse_pscr_cents_from_pdf +from .pscr_parser import parse_pscr_rates_from_pdf _LOGGER = logging.getLogger(__name__) @@ -65,8 +65,8 @@ async def _async_update_data(self) -> ParsedRateCard: return parsed try: - parsed.pscr_cents = await self.hass.async_add_executor_job( - parse_pscr_cents_from_pdf, + parsed.pscr_rates = await self.hass.async_add_executor_job( + parse_pscr_rates_from_pdf, pscr_bytes, ) parsed.pscr_source_url = PSCR_RATE_BOOK_URL diff --git a/custom_components/dte_rates/models.py b/custom_components/dte_rates/models.py index ace27e5..ef29bd0 100644 --- a/custom_components/dte_rates/models.py +++ b/custom_components/dte_rates/models.py @@ -50,5 +50,5 @@ class ParsedRateCard: effective_date: str | None rates: dict[str, RatePlan] raw_text_hash: str - pscr_cents: Decimal | None = None + pscr_rates: dict[str, Decimal] = field(default_factory=dict) pscr_source_url: str | None = None diff --git a/custom_components/dte_rates/pscr_parser.py b/custom_components/dte_rates/pscr_parser.py index c0707be..d8ff1b0 100644 --- a/custom_components/dte_rates/pscr_parser.py +++ b/custom_components/dte_rates/pscr_parser.py @@ -7,20 +7,46 @@ from pypdf import PdfReader -_D111_PSCR_RE = re.compile( - r"\bD1\.11\s+Standard\s+TOU\s+(?P\d+\.\d+)", +_RATE_ROW_RE = re.compile( + r"^\s*(?P[A-Z]\d+(?:\.\d+)?)\s+.+?\s+(?P-?\d+\.\d+)\s+\d+\.\d+", re.IGNORECASE, ) -def parse_pscr_cents_from_pdf(pdf_bytes: bytes) -> Decimal: - """Extract the current PSCR value from the MPSC DTE electric rate book.""" +def parse_pscr_rates_from_pdf(pdf_bytes: bytes) -> dict[str, Decimal]: + """Extract current PSCR values by tariff code from the MPSC DTE electric rate book.""" reader = PdfReader(io.BytesIO(pdf_bytes)) text = "\n".join(page.extract_text() or "" for page in reader.pages) - normalized = re.sub(r"\s+", " ", text) - - match = _D111_PSCR_RE.search(normalized) - if match: - return Decimal(match.group("pscr")) - - raise ValueError("MPSC DTE rate book does not contain a D1.11 PSCR value") + lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()] + + section = _power_supply_surcharge_lines(lines) + rates: dict[str, Decimal] = {} + for line in section: + match = _RATE_ROW_RE.match(line) + if match: + rates[match.group("code").upper()] = Decimal(match.group("pscr")) + + if not rates: + raise ValueError("MPSC DTE rate book does not contain PSCR tariff rows") + return rates + + +def _power_supply_surcharge_lines(lines: list[str]) -> list[str]: + start = None + for idx, line in enumerate(lines): + lower = line.lower() + if "c8.5 surcharges and credits applicable to power supply service" in lower: + start = idx + break + + if start is None: + return lines + + end = len(lines) + for idx in range(start + 1, len(lines)): + lower = lines[idx].lower() + if "c9 surcharges and credits applicable to delivery service" in lower: + end = idx + break + + return lines[start:end] diff --git a/custom_components/dte_rates/sensor.py b/custom_components/dte_rates/sensor.py index 68394c4..10b3cec 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -26,6 +26,8 @@ ATTR_NEXT_RATE_NAME, ATTR_NEXT_RATE_VALUE, ATTR_PSCR_CENTS, + ATTR_PSCR_RATE_CODE, + ATTR_PSCR_RATES, ATTR_PSCR_SOURCE_URL, ATTR_RATE_CODE, ATTR_RATE_NAME, @@ -110,27 +112,30 @@ def _active_period(self) -> SeasonalPeriodRate | None: return None return get_active_period(rate, dt_util.now()) - def _pscr_cents(self) -> Decimal | None: - return getattr(self.coordinator.data, "pscr_cents", None) + def _pscr_cents_for_rate(self, rate: RatePlan | None = None) -> Decimal | None: + target_rate = rate or self._selected_rate() + if target_rate is None: + return None + return getattr(self.coordinator.data, "pscr_rates", {}).get(target_rate.code) def _export_rate_cents(self, period: SeasonalPeriodRate): return current_export_rate_cents( period, self._entry.data.get(CONF_NET_METERING, False), - self._pscr_cents(), + self._pscr_cents_for_rate(), ) def _export_rate_source(self, period: SeasonalPeriodRate) -> str: if self._entry.data.get(CONF_NET_METERING, False): return "net_metering" - if rider18_export_formula_available(period, self._pscr_cents()): + if rider18_export_formula_available(period, self._pscr_cents_for_rate()): return "rider18_formula" return "rider18_formula_incomplete" def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None: if self._entry.data.get(CONF_NET_METERING, False): return None - if rider18_export_formula_available(period, self._pscr_cents()): + if rider18_export_formula_available(period, self._pscr_cents_for_rate()): return None return ( "Rider 18 formula is missing a generation component or PSCR value " @@ -152,8 +157,14 @@ def _base_attributes(self) -> dict: ATTR_SOURCE_URL: self.coordinator.data.source_url, ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date, } - if self.coordinator.data.pscr_cents is not None: - attrs[ATTR_PSCR_CENTS] = float(self.coordinator.data.pscr_cents) + pscr_cents = self._pscr_cents_for_rate() + if pscr_cents is not None: + attrs[ATTR_PSCR_CENTS] = float(pscr_cents) + rate = self._selected_rate() + if rate is not None: + attrs[ATTR_PSCR_RATE_CODE] = rate.code + if self.coordinator.data.pscr_rates: + attrs[ATTR_PSCR_RATES] = {code: float(value) for code, value in self.coordinator.data.pscr_rates.items()} if self.coordinator.data.pscr_source_url: attrs[ATTR_PSCR_SOURCE_URL] = self.coordinator.data.pscr_source_url @@ -301,7 +312,7 @@ def extra_state_attributes(self) -> dict: period = self._active_period() if period is not None: attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period) - attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents()) + attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents_for_rate()) warning = self._export_rate_warning(period) if warning is not None: attrs[ATTR_EXPORT_RATE_WARNING] = warning diff --git a/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md index 8fe1e24..2539093 100644 --- a/docs/research/rider18-export-rates.md +++ b/docs/research/rider18-export-rates.md @@ -4,11 +4,11 @@ - Enhancement request: calculate Rider 18 export credits to match DTE's Rider 18 calculator. - Residential rate card: source of the active generation components for each rate period. -- MPSC DTE electric rate book: source of the current PSCR scalar. +- MPSC DTE electric rate book: source of current PSCR factors by tariff code. ## Implementation Decision -Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. PSCR is parsed dynamically from the MPSC DTE electric rate book PDF at `https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf`, because the residential rate card PDF does not include that scalar. +Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. PSCR factors are parsed dynamically by tariff code from the MPSC DTE electric rate book PDF at `https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf`, because the residential rate card PDF does not include those factors. Formula: @@ -38,7 +38,9 @@ For net metering, keep the existing behavior: export uses the full active import ## UI/Entity Status -The setup flow explains that Rider 18 export credits use parsed generation plus PSCR when the PSCR value is available. Export entities expose whether the active period has enough data for the full formula: +The setup flow reports how many MPSC tariff PSCR factors were loaded. Export entities expose the selected rate's PSCR value, the PSCR source URL, and the full parsed `pscr_rates` map. + +Export entities expose whether the active period has enough data for the full formula: - `export_rate_source: rider18_formula` when generation components and PSCR are present. - `export_rate_source: rider18_formula_incomplete` when one side of the formula is missing. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7754bbf..01a5a6a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,5 +1,6 @@ from __future__ import annotations +from decimal import Decimal from unittest.mock import MagicMock import pytest @@ -23,7 +24,7 @@ async def _refresh(self): "D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]), }, raw_text_hash="abc", - pscr_cents=None, + pscr_rates={}, ) monkeypatch.setattr( @@ -58,6 +59,10 @@ async def _refresh(self): "D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]), }, raw_text_hash="abc", + pscr_rates={ + "D1.11": Decimal("1.877"), + "D1.13": Decimal("1.877"), + }, ) monkeypatch.setattr( @@ -70,5 +75,5 @@ async def _refresh(self): assert result["type"] == "form" assert ( result["description_placeholders"]["rider18_status"] - == "Rider 18 export credits use parsed generation rates plus PSCR when the PSCR value is available." + == "Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for 2 tariffs." ) diff --git a/tests/test_pscr_parser.py b/tests/test_pscr_parser.py index 49b78a7..62e56ef 100644 --- a/tests/test_pscr_parser.py +++ b/tests/test_pscr_parser.py @@ -2,14 +2,20 @@ from decimal import Decimal -from custom_components.dte_rates.pscr_parser import parse_pscr_cents_from_pdf +from custom_components.dte_rates.pscr_parser import parse_pscr_rates_from_pdf SAMPLE_RATE_BOOK_TEXT = """ C8.5 SURCHARGES AND CREDITS APPLICABLE TO POWER SUPPLY SERVICE Rate Schedule Description PSCR Factor Other Charges +D1 Non Transmitting Meter 1.877 0.0222 0.2305 2.1297 +D1.8 Dynamic Peak Pricing 1.877 0.0187 0.1934 2.0891 D1.11 Standard TOU 1.877 0.0221 -D1.13 Overnight Savers 1.877 0.0221 +D1.13 Overnight Savers 1.877 0.0221 0.2292 2.1283 +Commercial +D3 General Service 1.877 0.0180 0.1867 2.0817 +C9 SURCHARGES AND CREDITS APPLICABLE TO DELIVERY SERVICE +D1.11 Standard TOU 0.0890 0.4488 0.1617 0.2762 """ @@ -23,7 +29,13 @@ def __init__(self, _bytes): self.pages = [_FakePage()] -def test_parse_pscr_cents_from_mpsc_rate_book_pdf(monkeypatch): +def test_parse_pscr_rates_from_mpsc_rate_book_pdf(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.pscr_parser.PdfReader", _FakeReader) - assert parse_pscr_cents_from_pdf(b"fake") == Decimal("1.877") + assert parse_pscr_rates_from_pdf(b"fake") == { + "D1": Decimal("1.877"), + "D1.8": Decimal("1.877"), + "D1.11": Decimal("1.877"), + "D1.13": Decimal("1.877"), + "D3": Decimal("1.877"), + } diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 934696b..a190b82 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -42,7 +42,7 @@ def _coordinator_with_rate() -> SimpleNamespace: effective_date="February 6, 2025", rates={"D1.11": rate}, raw_text_hash="hash", - pscr_cents=Decimal("1.877"), + pscr_rates={"D1.11": Decimal("1.877"), "D1.13": Decimal("2.222")}, pscr_source_url="https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf", ) ) @@ -74,6 +74,7 @@ def test_export_sensor_uses_rider18_formula_without_net_metering(monkeypatch): assert sensor.extra_state_attributes["export_rate_source"] == "rider18_formula" assert sensor.extra_state_attributes["rider18_export_available"] is True assert sensor.extra_state_attributes["pscr_cents"] == 1.877 + assert sensor.extra_state_attributes["pscr_rate_code"] == "D1.11" def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): @@ -91,7 +92,7 @@ def test_export_sensor_reports_formula_unavailable_without_pscr(monkeypatch): monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: datetime(2026, 3, 1, 12, 0)) coordinator = _coordinator_with_rate() - coordinator.data.pscr_cents = None + coordinator.data.pscr_rates = {} coordinator.data.pscr_source_url = None entry = SimpleNamespace(entry_id="entry_16", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False})