diff --git a/custom_components/dte_rates/__init__.py b/custom_components/dte_rates/__init__.py index a031064..20ea0f2 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,22 @@ 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 + 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, False) / 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 0044d9f..5b11210 100644 --- a/custom_components/dte_rates/config_flow.py +++ b/custom_components/dte_rates/config_flow.py @@ -35,4 +35,18 @@ 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) -> 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 f2adde1..a42cd55 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" ) +PSCR_RATE_BOOK_URL = ( + "https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/" + "rate-books/electric/dte/dtee1cur.pdf" +) UPDATE_INTERVAL = timedelta(days=7) @@ -19,6 +23,13 @@ ATTR_COMPONENTS = "components" 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" +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/coordinator.py b/custom_components/dte_rates/coordinator.py index f7e83e2..93bd702 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 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_rates_from_pdf _LOGGER = logging.getLogger(__name__) @@ -33,11 +34,46 @@ 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( + 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 MPSC DTE rate book; 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_rates = await self.hass.async_add_executor_job( + parse_pscr_rates_from_pdf, + pscr_bytes, + ) + parsed.pscr_source_url = PSCR_RATE_BOOK_URL + except Exception as err: + _LOGGER.warning( + "Failed parsing MPSC DTE rate book 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..ef29bd0 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_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 new file mode 100644 index 0000000..d8ff1b0 --- /dev/null +++ b/custom_components/dte_rates/pscr_parser.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from decimal import Decimal +import io +import re + +from pypdf import PdfReader + + +_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_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) + 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/rate_calculator.py b/custom_components/dte_rates/rate_calculator.py index 36e4449..aaeb9a3 100644 --- a/custom_components/dte_rates/rate_calculator.py +++ b/custom_components/dte_rates/rate_calculator.py @@ -65,15 +65,35 @@ 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 _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, 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, 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, + pscr_cents: Decimal | None = None, +) -> Decimal: if net_metering: return period.components.per_kwh_total - 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, 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 c6f5ab0..10b3cec 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 @@ -19,11 +20,18 @@ 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_PSCR_CENTS, + ATTR_PSCR_RATE_CODE, + ATTR_PSCR_RATES, + ATTR_PSCR_SOURCE_URL, ATTR_RATE_CODE, ATTR_RATE_NAME, + ATTR_RIDER18_EXPORT_AVAILABLE, ATTR_SCHEDULE_BY_SEASON, ATTR_SCHEDULE_TEXT, ATTR_SEASON, @@ -41,6 +49,7 @@ get_active_period, get_next_rate_change, period_display_name, + rider18_export_formula_available, ) @@ -103,6 +112,36 @@ def _active_period(self) -> SeasonalPeriodRate | None: return None return get_active_period(rate, dt_util.now()) + 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_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_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_for_rate()): + return None + return ( + "Rider 18 formula is missing a generation component or PSCR value " + "for the active period; using the available formula components only." + ) + def _warning(self) -> str | None: selected = self._entry.data[CONF_SELECTED_RATE] if selected not in self.coordinator.data.rates: @@ -118,6 +157,16 @@ def _base_attributes(self) -> dict: ATTR_SOURCE_URL: self.coordinator.data.source_url, ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date, } + 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 rate = self._selected_rate() period = self._active_period() @@ -260,13 +309,19 @@ 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] = 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 return attrs 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 +383,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 +391,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/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/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..2539093 --- /dev/null +++ b/docs/research/rider18-export-rates.md @@ -0,0 +1,47 @@ +# Rider 18 Export Rates + +## Sources + +- 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 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 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: + +```text +Rider 18 export credit = generation components + PSCR +``` + +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 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. +- `export_rate_source: net_metering` when net metering is 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..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,6 +24,7 @@ async def _refresh(self): "D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]), }, raw_text_hash="abc", + pscr_rates={}, ) monkeypatch.setattr( @@ -41,3 +43,37 @@ 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_formula_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", + pscr_rates={ + "D1.11": Decimal("1.877"), + "D1.13": Decimal("1.877"), + }, + ) + + 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 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 new file mode 100644 index 0000000..62e56ef --- /dev/null +++ b/tests/test_pscr_parser.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from decimal import Decimal + +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 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 +""" + + +class _FakePage: + def extract_text(self): + return SAMPLE_RATE_BOOK_TEXT + + +class _FakeReader: + def __init__(self, _bytes): + self.pages = [_FakePage()] + + +def test_parse_pscr_rates_from_mpsc_rate_book_pdf(monkeypatch): + monkeypatch.setattr("custom_components.dte_rates.pscr_parser.PdfReader", _FakeReader) + + 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_rate_calculator.py b/tests/test_rate_calculator.py index 2a68008..98eae68 100644 --- a/tests/test_rate_calculator.py +++ b/tests/test_rate_calculator.py @@ -67,11 +67,28 @@ 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_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("14.407") + 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(): + 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"), + "misc_adjustment": Decimal("4.000"), + } + ), + window=TimeWindow(label="all_kwh"), + ) + + 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 c1c4a0d..a190b82 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_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", ) ) @@ -60,15 +62,47 @@ 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.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 + assert sensor.extra_state_attributes["pscr_rate_code"] == "D1.11" + + +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() + 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 + assert sensor.extra_state_attributes["export_rate_source"] == "net_metering" + + +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_rates = {} + 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) + attrs = sensor.extra_state_attributes + + assert sensor.native_value == 0.03 + assert attrs["export_rate_source"] == "rider18_formula_incomplete" + assert attrs["rider18_export_available"] is False + assert "PSCR" in attrs["export_rate_warning"] def test_sensor_warns_when_selected_rate_disappears(monkeypatch): @@ -105,7 +139,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): @@ -129,11 +163,23 @@ 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.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 +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() + 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.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): now = datetime(2026, 3, 1, 12, 0) monkeypatch.setattr("custom_components.dte_rates.sensor.dt_util.now", lambda: now)