From c3e7dded3ee62352f6b4cb53006e81f5ff7837e1 Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Sun, 17 May 2026 09:05:40 -0700 Subject: [PATCH 1/3] Add configurable PSCR and tax modifiers --- README.md | 13 +- custom_components/dte_rates/__init__.py | 9 +- custom_components/dte_rates/config_flow.py | 112 ++++++++++++++++-- custom_components/dte_rates/const.py | 9 +- .../dte_rates/rate_calculator.py | 41 +++++-- custom_components/dte_rates/sensor.py | 43 +++++-- custom_components/dte_rates/settings.py | 55 +++++++++ custom_components/dte_rates/strings.json | 24 +++- .../dte_rates/translations/en.json | 24 +++- docs/README.md | 5 + docs/research/issue-3-rate-modifiers.md | 30 +++++ docs/research/rider18-export-rates.md | 2 + tests/conftest.py | 8 ++ tests/test_config_flow.py | 36 +++++- tests/test_coordinator.py | 9 ++ tests/test_rate_calculator.py | 42 +++++++ tests/test_sensor.py | 48 ++++++-- 17 files changed, 459 insertions(+), 51 deletions(-) create mode 100644 custom_components/dte_rates/settings.py create mode 100644 docs/research/issue-3-rate-modifiers.md create mode 100644 tests/test_coordinator.py diff --git a/README.md b/README.md index da0294a..c948062 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A Home Assistant custom integration that pulls the official DTE residential elec - Downloads the live DTE Residential Electric Rate Card PDF. - Parses plans, periods, windows, and component pricing dynamically. -- Updates on a weekly schedule. +- Updates daily so current-month PSCR changes are picked up promptly. - Exposes entities for: - Current import rate (`USD/kWh`) - Current export rate (`USD/kWh`) @@ -25,6 +25,7 @@ A Home Assistant custom integration that pulls the official DTE residential elec - Home Assistant with support for custom integrations. - Internet access from Home Assistant to: - `dteenergy.com` (rate card PDF) + - `michigan.gov` (MPSC electric rate book PSCR data) ## Installation @@ -53,6 +54,8 @@ During setup you choose: - **Rate plan** from the currently parsed DTE PDF. - **Net metering enabled** (checkbox). +- **Include PSCR** (enabled by default). +- **Tax rate (%)** (free-form, defaults to `4.0`; set to `0` to omit tax). ## Entities Created @@ -78,6 +81,9 @@ Core rate entities include attributes such as: - `components` - `monthly_components` - `card_effective_date` +- `include_pscr` +- `tax_rate_percent` +- `pscr_cents` - `selected_rate_available` - `warning` (when selected plan disappears) @@ -140,8 +146,9 @@ Use `DTE Import Rate` as your current electricity price entity (`USD/kWh`) where ## Notes on Pricing Logic -- Import is full per-kWh total for the active period. -- Export is generation-only unless net metering is enabled. +- Import is full per-kWh total for the active period, plus PSCR when enabled, then the configured tax percentage. +- Rider 18 export is generation plus PSCR when enabled. Tax is not added to Rider 18 export credits. +- Export uses the modified import rate when net metering is enabled. - Next-rate calculations are schedule-aware and aligned to time boundaries. ## Project Layout diff --git a/custom_components/dte_rates/__init__.py b/custom_components/dte_rates/__init__.py index 20ea0f2..4a321dd 100644 --- a/custom_components/dte_rates/__init__.py +++ b/custom_components/dte_rates/__init__.py @@ -13,6 +13,7 @@ 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 +from .settings import entry_include_pscr, entry_tax_rate_percent PLATFORMS: list[Platform] = [Platform.SENSOR] SERVICE_REFRESH_RATE_CARD = "refresh_rate_card" @@ -123,9 +124,13 @@ 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_rates", {}).get(rate.code) + include_pscr = entry_include_pscr(entry) if entry else True + tax_rate_percent = entry_tax_rate_percent(entry) if entry else 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, pscr_cents) / 100) + import_usd = float(current_import_rate_cents(period, pscr_cents, include_pscr, tax_rate_percent) / 100) + export_usd = float( + current_export_rate_cents(period, net_metering, pscr_cents, include_pscr, tax_rate_percent) / 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 5b11210..4682073 100644 --- a/custom_components/dte_rates/config_flow.py +++ b/custom_components/dte_rates/config_flow.py @@ -4,13 +4,26 @@ from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult -from .const import CONF_NET_METERING, CONF_SELECTED_RATE, DOMAIN +from .const import ( + CONF_INCLUDE_PSCR, + CONF_NET_METERING, + CONF_SELECTED_RATE, + CONF_TAX_RATE, + DEFAULT_INCLUDE_PSCR, + DEFAULT_TAX_RATE_PERCENT, + DOMAIN, +) from .coordinator import DteRateCoordinator +from .settings import normalize_tax_rate_percent class DteRatesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + def async_get_options_flow(config_entry) -> config_entries.OptionsFlow: + return DteRatesOptionsFlow(config_entry) + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: coordinator = DteRateCoordinator(self.hass) await coordinator.async_refresh() @@ -22,31 +35,104 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: for code, rate in coordinator.data.rates.items() } + errors: dict[str, str] = {} if user_input is not None: - selected_rate = user_input[CONF_SELECTED_RATE] - return self.async_create_entry( - title=rate_options[selected_rate], - data=user_input, - ) - - schema = vol.Schema( - { - vol.Required(CONF_SELECTED_RATE): vol.In(rate_options), - vol.Optional(CONF_NET_METERING, default=False): bool, - } - ) + try: + data = _normalize_config_input(user_input) + except ValueError: + errors["base"] = "invalid_tax_rate" + else: + selected_rate = data[CONF_SELECTED_RATE] + return self.async_create_entry( + title=rate_options[selected_rate], + data=data, + ) + + schema = _user_schema(rate_options) return self.async_show_form( step_id="user", data_schema=schema, + errors=errors, description_placeholders={ "rider18_status": _rider18_status(coordinator.data), + "tax_note": _tax_note(), }, ) +class DteRatesOptionsFlow(config_entries.OptionsFlow): + def __init__(self, config_entry) -> None: + self.config_entry = config_entry + + async def async_step_init(self, user_input: dict | None = None) -> FlowResult: + errors: dict[str, str] = {} + if user_input is not None: + try: + data = _normalize_options_input(user_input) + except ValueError: + errors["base"] = "invalid_tax_rate" + else: + return self.async_create_entry(title="", data=data) + + return self.async_show_form( + step_id="init", + data_schema=_options_schema(self.config_entry), + errors=errors, + description_placeholders={"tax_note": _tax_note()}, + ) + + +def _normalize_config_input(user_input: dict) -> dict: + data = dict(user_input) + data.setdefault(CONF_INCLUDE_PSCR, DEFAULT_INCLUDE_PSCR) + data.setdefault(CONF_TAX_RATE, DEFAULT_TAX_RATE_PERCENT) + data[CONF_INCLUDE_PSCR] = bool(data[CONF_INCLUDE_PSCR]) + data[CONF_TAX_RATE] = normalize_tax_rate_percent(data[CONF_TAX_RATE]) + return data + + +def _normalize_options_input(user_input: dict) -> dict: + data = { + CONF_INCLUDE_PSCR: bool(user_input.get(CONF_INCLUDE_PSCR, DEFAULT_INCLUDE_PSCR)), + CONF_TAX_RATE: normalize_tax_rate_percent(user_input.get(CONF_TAX_RATE, DEFAULT_TAX_RATE_PERCENT)), + } + return data + + +def _user_schema(rate_options: dict[str, str]) -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_SELECTED_RATE): vol.In(rate_options), + vol.Optional(CONF_NET_METERING, default=False): bool, + vol.Optional(CONF_INCLUDE_PSCR, default=DEFAULT_INCLUDE_PSCR): bool, + vol.Optional(CONF_TAX_RATE, default=DEFAULT_TAX_RATE_PERCENT): str, + } + ) + + +def _options_schema(config_entry) -> vol.Schema: + options = getattr(config_entry, "options", None) or {} + data = getattr(config_entry, "data", None) or {} + include_pscr = options.get(CONF_INCLUDE_PSCR, data.get(CONF_INCLUDE_PSCR, DEFAULT_INCLUDE_PSCR)) + tax_rate = options.get(CONF_TAX_RATE, data.get(CONF_TAX_RATE, DEFAULT_TAX_RATE_PERCENT)) + return vol.Schema( + { + vol.Optional(CONF_INCLUDE_PSCR, default=include_pscr): bool, + vol.Optional(CONF_TAX_RATE, default=tax_rate): str, + } + ) + + 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." + + +def _tax_note() -> str: + return ( + "Default tax is Michigan's 4.0% residential electric sales tax. " + "Set tax rate to 0 to omit taxes from rate calculations." + ) diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index a42cd55..a07df18 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -11,10 +11,15 @@ "rate-books/electric/dte/dtee1cur.pdf" ) -UPDATE_INTERVAL = timedelta(days=7) +UPDATE_INTERVAL = timedelta(days=1) CONF_SELECTED_RATE = "selected_rate" CONF_NET_METERING = "net_metering" +CONF_INCLUDE_PSCR = "include_pscr" +CONF_TAX_RATE = "tax_rate" + +DEFAULT_INCLUDE_PSCR = True +DEFAULT_TAX_RATE_PERCENT = "4.0" ATTR_RATE_CODE = "rate_code" ATTR_RATE_NAME = "rate_name" @@ -30,6 +35,8 @@ ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available" ATTR_EXPORT_RATE_SOURCE = "export_rate_source" ATTR_EXPORT_RATE_WARNING = "export_rate_warning" +ATTR_INCLUDE_PSCR = "include_pscr" +ATTR_TAX_RATE_PERCENT = "tax_rate_percent" ATTR_CARD_EFFECTIVE_DATE = "card_effective_date" ATTR_SELECTED_RATE_AVAILABLE = "selected_rate_available" ATTR_WARNING = "warning" diff --git a/custom_components/dte_rates/rate_calculator.py b/custom_components/dte_rates/rate_calculator.py index aaeb9a3..e941cfd 100644 --- a/custom_components/dte_rates/rate_calculator.py +++ b/custom_components/dte_rates/rate_calculator.py @@ -61,8 +61,16 @@ def get_active_period(rate: RatePlan, now: datetime) -> SeasonalPeriodRate | Non return None -def current_import_rate_cents(period: SeasonalPeriodRate) -> Decimal: - return period.components.per_kwh_total +def current_import_rate_cents( + period: SeasonalPeriodRate, + pscr_cents: Decimal | None = None, + include_pscr: bool = False, + tax_rate_percent: Decimal | None = None, +) -> Decimal: + total = period.components.per_kwh_total + if include_pscr and pscr_cents is not None: + total += pscr_cents + return _apply_tax(total, tax_rate_percent) def _component_total(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> Decimal: @@ -77,23 +85,40 @@ 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_rate_cents( + period: SeasonalPeriodRate, + pscr_cents: Decimal | None = None, + include_pscr: bool = True, +) -> Decimal: + pscr = pscr_cents if include_pscr and pscr_cents is not None else Decimal("0") + return _component_total(period, GENERATION_COMPONENT_MARKERS) + pscr -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 rider18_export_formula_available( + period: SeasonalPeriodRate, + pscr_cents: Decimal | None = None, + include_pscr: bool = True, +) -> bool: + return _has_component(period, GENERATION_COMPONENT_MARKERS) and (not include_pscr or pscr_cents is not None) def current_export_rate_cents( period: SeasonalPeriodRate, net_metering: bool, pscr_cents: Decimal | None = None, + include_pscr: bool = True, + tax_rate_percent: Decimal | None = None, ) -> Decimal: if net_metering: - return period.components.per_kwh_total + return current_import_rate_cents(period, pscr_cents, include_pscr, tax_rate_percent) + + return rider18_export_rate_cents(period, pscr_cents, include_pscr) + - return rider18_export_rate_cents(period, pscr_cents) +def _apply_tax(value: Decimal, tax_rate_percent: Decimal | None) -> Decimal: + if tax_rate_percent is None: + return value + return value * (Decimal("1") + tax_rate_percent / Decimal("100")) def period_display_name(period: SeasonalPeriodRate) -> str: diff --git a/custom_components/dte_rates/sensor.py b/custom_components/dte_rates/sensor.py index 10b3cec..a5eeef3 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -22,6 +22,7 @@ ATTR_CURRENT_RATE_NAME, ATTR_EXPORT_RATE_SOURCE, ATTR_EXPORT_RATE_WARNING, + ATTR_INCLUDE_PSCR, ATTR_NEXT_RATE_CHANGE, ATTR_NEXT_RATE_NAME, ATTR_NEXT_RATE_VALUE, @@ -37,6 +38,7 @@ ATTR_SEASON, ATTR_SELECTED_RATE_AVAILABLE, ATTR_SOURCE_URL, + ATTR_TAX_RATE_PERCENT, ATTR_WARNING, CONF_NET_METERING, CONF_SELECTED_RATE, @@ -51,6 +53,7 @@ period_display_name, rider18_export_formula_available, ) +from .settings import entry_include_pscr, entry_tax_rate_percent async def async_setup_entry( @@ -118,24 +121,40 @@ def _pscr_cents_for_rate(self, rate: RatePlan | None = None) -> Decimal | None: return None return getattr(self.coordinator.data, "pscr_rates", {}).get(target_rate.code) + def _include_pscr(self) -> bool: + return entry_include_pscr(self._entry) + + def _tax_rate_percent(self) -> Decimal: + return entry_tax_rate_percent(self._entry) + + def _import_rate_cents(self, period: SeasonalPeriodRate) -> Decimal: + return current_import_rate_cents( + period, + self._pscr_cents_for_rate(), + self._include_pscr(), + self._tax_rate_percent(), + ) + 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(), + self._include_pscr(), + self._tax_rate_percent(), ) 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()): + if rider18_export_formula_available(period, self._pscr_cents_for_rate(), self._include_pscr()): 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()): + if rider18_export_formula_available(period, self._pscr_cents_for_rate(), self._include_pscr()): return None return ( "Rider 18 formula is missing a generation component or PSCR value " @@ -156,6 +175,8 @@ def _base_attributes(self) -> dict: ATTR_WARNING: warning, ATTR_SOURCE_URL: self.coordinator.data.source_url, ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date, + ATTR_INCLUDE_PSCR: self._include_pscr(), + ATTR_TAX_RATE_PERCENT: float(self._tax_rate_percent()), } pscr_cents = self._pscr_cents_for_rate() if pscr_cents is not None: @@ -222,8 +243,8 @@ def _schedule_rows(self, rate: RatePlan) -> list[dict]: "period": period.period_name, "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(self._period_value_usd(period) or 0.0), 6), + "import_usd_per_kwh": round(float(self._import_rate_cents(period) / 100), 6), + "export_usd_per_kwh": round(float(self._export_rate_cents(period) / 100), 6), } ) return rows @@ -288,7 +309,7 @@ def extra_state_attributes(self) -> dict: def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None: if period is None: return None - return float(current_import_rate_cents(period) / 100) + return float(self._import_rate_cents(period) / 100) class DteExportRateSensor(_DteBaseRateSensor): @@ -312,7 +333,11 @@ 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_for_rate()) + attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available( + period, + self._pscr_cents_for_rate(), + self._include_pscr(), + ) warning = self._export_rate_warning(period) if warning is not None: attrs[ATTR_EXPORT_RATE_WARNING] = warning @@ -349,7 +374,7 @@ def extra_state_attributes(self) -> dict: def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None: if period is None: return None - return float(current_import_rate_cents(period) / 100) + return float(self._import_rate_cents(period) / 100) class DteRateScheduleSensor(_DteBaseRateSensor): @@ -390,7 +415,7 @@ def _schedule_rows(self, rate: RatePlan) -> list[dict]: "period": period.period_name, "name": period_display_name(period), "time_window": self._window_summary(period), - "import_usd_per_kwh": round(float(current_import_rate_cents(period) / 100), 6), + "import_usd_per_kwh": round(float(self._import_rate_cents(period) / 100), 6), "export_usd_per_kwh": round(float(self._export_rate_cents(period) / 100), 6), } ) @@ -416,4 +441,4 @@ def _schedule_text_from_rows(self, rows: list[dict]) -> str: def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None: if period is None: return None - return float(current_import_rate_cents(period) / 100) + return float(self._import_rate_cents(period) / 100) diff --git a/custom_components/dte_rates/settings.py b/custom_components/dte_rates/settings.py new file mode 100644 index 0000000..55f3d28 --- /dev/null +++ b/custom_components/dte_rates/settings.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Any + +from .const import ( + CONF_INCLUDE_PSCR, + CONF_TAX_RATE, + DEFAULT_INCLUDE_PSCR, + DEFAULT_TAX_RATE_PERCENT, +) + + +def parse_tax_rate_percent(value: Any) -> Decimal: + if value is None or value == "": + value = DEFAULT_TAX_RATE_PERCENT + + raw = str(value).strip() + if raw.endswith("%"): + raw = raw[:-1].strip() + + try: + tax_rate = Decimal(raw) + except (InvalidOperation, ValueError) as err: + raise ValueError("Tax rate must be a non-negative percentage.") from err + + if tax_rate < 0: + raise ValueError("Tax rate must be a non-negative percentage.") + return tax_rate + + +def normalize_tax_rate_percent(value: Any) -> str: + return str(parse_tax_rate_percent(value)) + + +def entry_include_pscr(entry) -> bool: + return _as_bool(_entry_setting(entry, CONF_INCLUDE_PSCR, DEFAULT_INCLUDE_PSCR)) + + +def entry_tax_rate_percent(entry) -> Decimal: + return parse_tax_rate_percent(_entry_setting(entry, CONF_TAX_RATE, DEFAULT_TAX_RATE_PERCENT)) + + +def _entry_setting(entry, key: str, default: Any) -> Any: + options = getattr(entry, "options", None) or {} + if key in options: + return options[key] + data = getattr(entry, "data", None) or {} + return data.get(key, default) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() not in {"0", "false", "no", "off"} + return bool(value) diff --git a/custom_components/dte_rates/strings.json b/custom_components/dte_rates/strings.json index 7de034f..7907018 100644 --- a/custom_components/dte_rates/strings.json +++ b/custom_components/dte_rates/strings.json @@ -3,12 +3,32 @@ "step": { "user": { "title": "DTE Residential Rates", - "description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}", + "description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}\n\n{tax_note}", "data": { "selected_rate": "Rate plan", - "net_metering": "Net metering enabled" + "net_metering": "Net metering enabled", + "include_pscr": "Include PSCR", + "tax_rate": "Tax rate (%)" } } + }, + "error": { + "invalid_tax_rate": "Tax rate must be a non-negative percentage." + } + }, + "options": { + "step": { + "init": { + "title": "DTE Residential Rate Modifiers", + "description": "{tax_note}", + "data": { + "include_pscr": "Include PSCR", + "tax_rate": "Tax rate (%)" + } + } + }, + "error": { + "invalid_tax_rate": "Tax rate must be a non-negative percentage." } }, "services": { diff --git a/custom_components/dte_rates/translations/en.json b/custom_components/dte_rates/translations/en.json index 7de034f..7907018 100644 --- a/custom_components/dte_rates/translations/en.json +++ b/custom_components/dte_rates/translations/en.json @@ -3,12 +3,32 @@ "step": { "user": { "title": "DTE Residential Rates", - "description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}", + "description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}\n\n{tax_note}", "data": { "selected_rate": "Rate plan", - "net_metering": "Net metering enabled" + "net_metering": "Net metering enabled", + "include_pscr": "Include PSCR", + "tax_rate": "Tax rate (%)" } } + }, + "error": { + "invalid_tax_rate": "Tax rate must be a non-negative percentage." + } + }, + "options": { + "step": { + "init": { + "title": "DTE Residential Rate Modifiers", + "description": "{tax_note}", + "data": { + "include_pscr": "Include PSCR", + "tax_rate": "Tax rate (%)" + } + } + }, + "error": { + "invalid_tax_rate": "Tax rate must be a non-negative percentage." } }, "services": { diff --git a/docs/README.md b/docs/README.md index b9b7ac4..f2f1202 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,3 +7,8 @@ This directory stores research and implementation notes that support changes to - `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. + +Current research notes: + +- `research/rider18-export-rates.md` - Rider 18 export-rate source mapping and PSCR formula decisions. +- `research/issue-3-rate-modifiers.md` - PSCR/tax modifier defaults and calculation scope for real out-of-pocket rates. diff --git a/docs/research/issue-3-rate-modifiers.md b/docs/research/issue-3-rate-modifiers.md new file mode 100644 index 0000000..63f35ce --- /dev/null +++ b/docs/research/issue-3-rate-modifiers.md @@ -0,0 +1,30 @@ +# Issue 3 Rate Modifiers + +## Source + +- GitHub issue: https://github.com/javaDevJT/DTE-Rates-for-Home-Assistant/issues/3 +- Title: Rate modifiers for real out-of-pocket rates +- Created: 2026-05-16 + +## Request Summary + +The issue asks for rate modifiers so Home Assistant can show a closer out-of-pocket cost instead of only the raw DTE rate-card kWh values. The requested modifiers are PSCR and taxes, with controls to omit them when the user wants base tariff pricing. + +## Decisions + +- PSCR is included by default because the current rate card does not include it, and the integration already parses tariff-specific PSCR values from the MPSC DTE electric rate book. +- The refresh interval is daily so the integration re-checks the current-month PSCR source regularly instead of waiting up to a week. +- Users can disable PSCR with an `include_pscr` setting. When disabled, Rider 18 export credits intentionally use generation-only formula components and should not report a missing-PSCR warning. +- The default tax rate is `4.0%`. Michigan Treasury describes residential electricity as taxed at a 4% rate, and MPSC residential bill-charge guidance says utility companies collect 4% sales tax from residential customers. +- The tax rate remains free-form as `tax_rate`, labeled as a percentage. Users can enter `0` to omit taxes from rate calculations. + +## Calculation Scope + +- Import rates apply PSCR when enabled, then apply the configured tax percentage. +- Rider 18 export credits apply PSCR when enabled but do not apply tax. +- Net-metering export rates use the same modified import rate because they represent the avoided import cost for the selected rate. + +## Reference Links + +- Michigan Treasury sales and use tax page: https://www.michigan.gov/taxes/business-taxes/sales-use-tax +- MPSC residential electric bill charges PDF: https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/info/tips/electric_residential_bill_charges_final.pdf?rev=e80ef0b163614800b1d8910b9e781775 diff --git a/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md index 2539093..1d80b2f 100644 --- a/docs/research/rider18-export-rates.md +++ b/docs/research/rider18-export-rates.md @@ -45,3 +45,5 @@ Export entities expose whether the active period has enough data for the full fo - `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. + +Issue 3 added an `include_pscr` setting. When PSCR is intentionally disabled, the Rider 18 formula uses generation components only and does not treat the omitted PSCR add-on as an incomplete parse. diff --git a/tests/conftest.py b/tests/conftest.py index b1935dd..bae86db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,13 @@ def async_show_form(self, *, step_id, data_schema=None, errors=None, **kwargs): def async_create_entry(self, *, title, data): return FlowResult(type="create_entry", title=title, data=data) + class OptionsFlow: + 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 {}, **kwargs) + + def async_create_entry(self, *, title, data): + return FlowResult(type="create_entry", title=title, data=data) + class ConfigEntry: pass @@ -109,6 +116,7 @@ def __init__(self, url_path, path, cache_headers=True): ConfigFlow=ConfigFlow, ConfigEntry=ConfigEntry, FlowResult=FlowResult, + OptionsFlow=OptionsFlow, ), "homeassistant.data_entry_flow": _make_module("homeassistant.data_entry_flow", FlowResult=FlowResult), "homeassistant.core": _make_module("homeassistant.core", HomeAssistant=HomeAssistant, callback=callback), diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 01a5a6a..9f4cd6e 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -5,8 +5,13 @@ import pytest -from custom_components.dte_rates.config_flow import DteRatesConfigFlow -from custom_components.dte_rates.const import CONF_NET_METERING, CONF_SELECTED_RATE +from custom_components.dte_rates.config_flow import DteRatesConfigFlow, DteRatesOptionsFlow +from custom_components.dte_rates.const import ( + CONF_INCLUDE_PSCR, + CONF_NET_METERING, + CONF_SELECTED_RATE, + CONF_TAX_RATE, +) from custom_components.dte_rates.models import ParsedRateCard, RatePlan @@ -43,6 +48,8 @@ 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 + assert result["data"][CONF_INCLUDE_PSCR] is True + assert result["data"][CONF_TAX_RATE] == "4.0" @pytest.mark.asyncio @@ -77,3 +84,28 @@ async def _refresh(self): result["description_placeholders"]["rider18_status"] == "Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for 2 tariffs." ) + assert "Set tax rate to 0" in result["description_placeholders"]["tax_note"] + + +@pytest.mark.asyncio +async def test_options_flow_updates_modifiers(): + config_entry = MagicMock() + config_entry.data = { + CONF_SELECTED_RATE: "D1.11", + CONF_NET_METERING: False, + CONF_INCLUDE_PSCR: True, + CONF_TAX_RATE: "4.0", + } + config_entry.options = {} + flow = DteRatesOptionsFlow(config_entry) + + result = await flow.async_step_init( + { + CONF_INCLUDE_PSCR: False, + CONF_TAX_RATE: "0", + } + ) + + assert result["type"] == "create_entry" + assert result["data"][CONF_INCLUDE_PSCR] is False + assert result["data"][CONF_TAX_RATE] == "0" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..627af13 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from datetime import timedelta + +from custom_components.dte_rates.const import UPDATE_INTERVAL + + +def test_rate_card_and_pscr_refresh_daily_for_current_month_accuracy(): + assert UPDATE_INTERVAL <= timedelta(days=1) diff --git a/tests/test_rate_calculator.py b/tests/test_rate_calculator.py index 98eae68..aedc721 100644 --- a/tests/test_rate_calculator.py +++ b/tests/test_rate_calculator.py @@ -10,6 +10,7 @@ get_active_period, get_next_rate_change, period_display_name, + rider18_export_formula_available, ) @@ -67,6 +68,19 @@ def test_import_is_total_of_all_per_kwh_components(): assert current_import_rate_cents(active) == Decimal("24.133") +def test_import_can_include_pscr_and_tax(): + rate = _rate_plan() + active = get_active_period(rate, datetime(2026, 6, 1, 16, 30)) + assert active is not None + + assert current_import_rate_cents( + active, + pscr_cents=Decimal("1.877"), + include_pscr=True, + tax_rate_percent=Decimal("4.0"), + ) == Decimal("27.05040") + + 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)) @@ -74,6 +88,20 @@ def test_rider18_export_without_net_metering_matches_spreadsheet_generation_plus assert current_export_rate_cents(active, net_metering=False, pscr_cents=Decimal("1.877")) == Decimal("16.284") +def test_rider18_export_can_omit_pscr_when_toggled_off(): + 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, + pscr_cents=Decimal("1.877"), + include_pscr=False, + ) == Decimal("14.407") + assert rider18_export_formula_available(active, None, include_pscr=False) is True + + def test_rider18_export_excludes_unrelated_non_formula_components(): period = SeasonalPeriodRate( season_name="year_round", @@ -98,6 +126,20 @@ def test_export_with_net_metering_uses_total(): assert current_export_rate_cents(active, net_metering=True) == Decimal("24.133") +def test_net_metering_export_uses_import_modifiers(): + 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=True, + pscr_cents=Decimal("1.877"), + include_pscr=True, + tax_rate_percent=Decimal("4.0"), + ) == Decimal("27.05040") + + def test_multiple_hour_ranges_supported_for_same_period(): rate = RatePlan( code="D1.8", diff --git a/tests/test_sensor.py b/tests/test_sensor.py index a190b82..e5a2c10 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -7,7 +7,12 @@ import pytest from homeassistant.components import persistent_notification -from custom_components.dte_rates.const import CONF_NET_METERING, CONF_SELECTED_RATE +from custom_components.dte_rates.const import ( + CONF_INCLUDE_PSCR, + CONF_NET_METERING, + CONF_SELECTED_RATE, + CONF_TAX_RATE, +) from custom_components.dte_rates.models import ParsedRateCard, PriceComponents, RatePlan, SeasonalPeriodRate, TimeWindow from custom_components.dte_rates.sensor import ( DteCurrentRateNameSensor, @@ -48,18 +53,43 @@ def _coordinator_with_rate() -> SimpleNamespace: ) -def test_import_sensor_returns_total_rate(monkeypatch): +def test_import_sensor_returns_default_out_of_pocket_rate(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_1", data={CONF_SELECTED_RATE: "D1.11", CONF_NET_METERING: False}) sensor = DteImportRateSensor(coordinator, entry) - assert sensor.native_value == 0.06 + assert sensor.native_value == pytest.approx(0.0819208) assert sensor.native_unit_of_measurement == "USD/kWh" - assert sensor.extra_state_attributes["selected_rate_available"] is True - assert sensor.extra_state_attributes["current_rate_name"] == "All kWh" - assert sensor.extra_state_attributes["next_rate_change"] is None + attrs = sensor.extra_state_attributes + assert attrs["selected_rate_available"] is True + assert attrs["current_rate_name"] == "All kWh" + assert attrs["next_rate_change"] is None + assert attrs["include_pscr"] is True + assert attrs["tax_rate_percent"] == 4.0 + + +def test_import_sensor_can_omit_modifiers(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_17", + data={ + CONF_SELECTED_RATE: "D1.11", + CONF_NET_METERING: False, + CONF_INCLUDE_PSCR: False, + CONF_TAX_RATE: "0", + }, + ) + + sensor = DteImportRateSensor(coordinator, entry) + attrs = sensor.extra_state_attributes + + assert sensor.native_value == 0.06 + assert attrs["include_pscr"] is False + assert attrs["tax_rate_percent"] == 0.0 def test_export_sensor_uses_rider18_formula_without_net_metering(monkeypatch): @@ -84,7 +114,7 @@ def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): 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.native_value == pytest.approx(0.0819208) assert sensor.extra_state_attributes["export_rate_source"] == "net_metering" @@ -162,7 +192,7 @@ 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 "Import $0.0819/kWh" in attrs["schedule_text"] 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 @@ -205,7 +235,7 @@ def test_schedule_sensor_next_rate_value_defaults_to_import(monkeypatch): sensor = DteRateScheduleSensor(coordinator, entry) attrs = sensor.extra_state_attributes - assert attrs["next_rate_value"] == 0.07 + assert attrs["next_rate_value"] == pytest.approx(0.0923208) def test_entities_publish_service_device_info(monkeypatch): From fb15f1204c30afa6e48bb4ba1c8367b39d73ee2a Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Sun, 17 May 2026 09:32:31 -0700 Subject: [PATCH 2/3] Expose current rate calculation details --- README.md | 3 + custom_components/dte_rates/config_flow.py | 1 + custom_components/dte_rates/const.py | 2 + custom_components/dte_rates/sensor.py | 83 ++++++++++++++++++++++ docs/research/issue-3-rate-modifiers.md | 3 + tests/test_config_flow.py | 1 + tests/test_sensor.py | 21 ++++++ 7 files changed, 114 insertions(+) diff --git a/README.md b/README.md index c948062..f2b9530 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ During setup you choose: - **Net metering enabled** (checkbox). - **Include PSCR** (enabled by default). - **Tax rate (%)** (free-form, defaults to `4.0`; set to `0` to omit tax). +- Detroit residents may incur an additional 5.0% City Utility Users' Tax; adjust the free-form tax rate if that applies to your service address. ## Entities Created @@ -84,6 +85,8 @@ Core rate entities include attributes such as: - `include_pscr` - `tax_rate_percent` - `pscr_cents` +- `current_rate_formula` +- `current_rate_calculation` - `selected_rate_available` - `warning` (when selected plan disappears) diff --git a/custom_components/dte_rates/config_flow.py b/custom_components/dte_rates/config_flow.py index 4682073..a62d13b 100644 --- a/custom_components/dte_rates/config_flow.py +++ b/custom_components/dte_rates/config_flow.py @@ -134,5 +134,6 @@ def _rider18_status(rate_card) -> str: def _tax_note() -> str: return ( "Default tax is Michigan's 4.0% residential electric sales tax. " + "Detroit residents may incur an additional 5.0% City Utility Users' Tax. " "Set tax rate to 0 to omit taxes from rate calculations." ) diff --git a/custom_components/dte_rates/const.py b/custom_components/dte_rates/const.py index a07df18..42580e2 100644 --- a/custom_components/dte_rates/const.py +++ b/custom_components/dte_rates/const.py @@ -41,6 +41,8 @@ ATTR_SELECTED_RATE_AVAILABLE = "selected_rate_available" ATTR_WARNING = "warning" ATTR_CURRENT_RATE_NAME = "current_rate_name" +ATTR_CURRENT_RATE_CALCULATION = "current_rate_calculation" +ATTR_CURRENT_RATE_FORMULA = "current_rate_formula" ATTR_NEXT_RATE_CHANGE = "next_rate_change" ATTR_NEXT_RATE_NAME = "next_rate_name" ATTR_NEXT_RATE_VALUE = "next_rate_value" diff --git a/custom_components/dte_rates/sensor.py b/custom_components/dte_rates/sensor.py index a5eeef3..e43405b 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -19,6 +19,8 @@ ATTR_COMPONENTS, ATTR_MONTHLY_COMPONENTS, ATTR_PERIOD, + ATTR_CURRENT_RATE_CALCULATION, + ATTR_CURRENT_RATE_FORMULA, ATTR_CURRENT_RATE_NAME, ATTR_EXPORT_RATE_SOURCE, ATTR_EXPORT_RATE_WARNING, @@ -46,6 +48,7 @@ ) from .models import RatePlan, SeasonalPeriodRate from .rate_calculator import ( + GENERATION_COMPONENT_MARKERS, current_export_rate_cents, current_import_rate_cents, get_active_period, @@ -161,6 +164,80 @@ def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None: "for the active period; using the available formula components only." ) + def _import_rate_calculation(self, period: SeasonalPeriodRate, mode: str = "import") -> dict: + base = period.components.per_kwh_total + pscr = self._pscr_cents_for_rate() + pscr_applied = pscr if self._include_pscr() and pscr is not None else Decimal("0") + pre_tax = base + pscr_applied + tax_rate = self._tax_rate_percent() + tax = pre_tax * tax_rate / Decimal("100") + total = pre_tax + tax + + formula = ( + f"((base {self._fmt(base)} + PSCR {self._fmt(pscr_applied)}) " + f"* (1 + tax {self._fmt(tax_rate)}%)) / 100 = ${self._fmt_usd(total)}/kWh" + ) + if mode == "net_metering_export": + formula = f"net metering export uses modified import rate: {formula}" + + return { + "mode": mode, + "base_cents_per_kwh": self._as_float(base), + "pscr_cents_per_kwh": self._as_float(pscr), + "pscr_included": self._include_pscr() and pscr is not None, + "tax_rate_percent": self._as_float(tax_rate), + "tax_applied": tax_rate != 0, + "pre_tax_cents_per_kwh": self._as_float(pre_tax), + "tax_cents_per_kwh": self._as_float(tax), + "total_cents_per_kwh": self._as_float(total), + "total_usd_per_kwh": self._as_float(total / Decimal("100")), + "formula": formula, + } + + def _export_rate_calculation(self, period: SeasonalPeriodRate) -> dict: + if self._entry.data.get(CONF_NET_METERING, False): + return self._import_rate_calculation(period, "net_metering_export") + + generation = sum( + value + for key, value in period.components.per_kwh.items() + if any(marker in key for marker in GENERATION_COMPONENT_MARKERS) + ) + pscr = self._pscr_cents_for_rate() + pscr_applied = pscr if self._include_pscr() and pscr is not None else Decimal("0") + total = generation + pscr_applied + formula = ( + f"(generation {self._fmt(generation)} + PSCR {self._fmt(pscr_applied)}) " + f"/ 100 = ${self._fmt_usd(total)}/kWh" + ) + + return { + "mode": "rider18_export", + "generation_cents_per_kwh": self._as_float(generation), + "pscr_cents_per_kwh": self._as_float(pscr), + "pscr_included": self._include_pscr() and pscr is not None, + "tax_rate_percent": self._as_float(self._tax_rate_percent()), + "tax_applied": False, + "total_cents_per_kwh": self._as_float(total), + "total_usd_per_kwh": self._as_float(total / Decimal("100")), + "formula": formula, + } + + def _period_calculation(self, period: SeasonalPeriodRate) -> dict: + return self._import_rate_calculation(period) + + @staticmethod + def _as_float(value: Decimal | None) -> float | None: + return float(value) if value is not None else None + + @staticmethod + def _fmt(value: Decimal) -> str: + return f"{value:.4f}" + + @staticmethod + def _fmt_usd(cents: Decimal) -> str: + return f"{(cents / Decimal('100')):.6f}" + def _warning(self) -> str | None: selected = self._entry.data[CONF_SELECTED_RATE] if selected not in self.coordinator.data.rates: @@ -212,6 +289,9 @@ def _base_attributes(self) -> dict: ATTR_MONTHLY_COMPONENTS: {k: float(v) for k, v in period.components.monthly.items()}, } ) + calculation = self._period_calculation(period) + attrs[ATTR_CURRENT_RATE_CALCULATION] = calculation + attrs[ATTR_CURRENT_RATE_FORMULA] = calculation["formula"] return attrs def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None: @@ -348,6 +428,9 @@ def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None: return None return float(self._export_rate_cents(period) / 100) + def _period_calculation(self, period: SeasonalPeriodRate) -> dict: + return self._export_rate_calculation(period) + class DteCurrentRateNameSensor(_DteBaseRateSensor): _attr_native_unit_of_measurement = None diff --git a/docs/research/issue-3-rate-modifiers.md b/docs/research/issue-3-rate-modifiers.md index 63f35ce..f4e59bc 100644 --- a/docs/research/issue-3-rate-modifiers.md +++ b/docs/research/issue-3-rate-modifiers.md @@ -17,6 +17,8 @@ The issue asks for rate modifiers so Home Assistant can show a closer out-of-poc - Users can disable PSCR with an `include_pscr` setting. When disabled, Rider 18 export credits intentionally use generation-only formula components and should not report a missing-PSCR warning. - The default tax rate is `4.0%`. Michigan Treasury describes residential electricity as taxed at a 4% rate, and MPSC residential bill-charge guidance says utility companies collect 4% sales tax from residential customers. - The tax rate remains free-form as `tax_rate`, labeled as a percentage. Users can enter `0` to omit taxes from rate calculations. +- Detroit residents may incur an additional `5.0%` City Utility Users' Tax. The UI note mentions this but does not change the default, because the integration does not currently know the service address. +- Rate entities expose `current_rate_formula` and `current_rate_calculation` attributes so users can see the PSCR/tax inputs and exact math used for the active period. ## Calculation Scope @@ -28,3 +30,4 @@ The issue asks for rate modifiers so Home Assistant can show a closer out-of-poc - Michigan Treasury sales and use tax page: https://www.michigan.gov/taxes/business-taxes/sales-use-tax - MPSC residential electric bill charges PDF: https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/info/tips/electric_residential_bill_charges_final.pdf?rev=e80ef0b163614800b1d8910b9e781775 +- City of Detroit September 2019 Revenue Estimating Conference Report: https://detroitmi.gov/sites/detroitmi.localhost/files/migrated_docs/financial-reports/Sept2019RevenueEstimatingConferenceReportFINAL.pdf diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 9f4cd6e..550ca9a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -85,6 +85,7 @@ async def _refresh(self): == "Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for 2 tariffs." ) assert "Set tax rate to 0" in result["description_placeholders"]["tax_note"] + assert "Detroit residents may incur an additional 5.0%" in result["description_placeholders"]["tax_note"] @pytest.mark.asyncio diff --git a/tests/test_sensor.py b/tests/test_sensor.py index e5a2c10..caade61 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -68,6 +68,20 @@ def test_import_sensor_returns_default_out_of_pocket_rate(monkeypatch): assert attrs["next_rate_change"] is None assert attrs["include_pscr"] is True assert attrs["tax_rate_percent"] == 4.0 + assert attrs["current_rate_formula"] == "((base 6.0000 + PSCR 1.8770) * (1 + tax 4.0000%)) / 100 = $0.081921/kWh" + assert attrs["current_rate_calculation"] == { + "mode": "import", + "base_cents_per_kwh": 6.0, + "pscr_cents_per_kwh": 1.877, + "pscr_included": True, + "tax_rate_percent": 4.0, + "tax_applied": True, + "pre_tax_cents_per_kwh": 7.877, + "tax_cents_per_kwh": 0.31508, + "total_cents_per_kwh": 8.19208, + "total_usd_per_kwh": 0.0819208, + "formula": "((base 6.0000 + PSCR 1.8770) * (1 + tax 4.0000%)) / 100 = $0.081921/kWh", + } def test_import_sensor_can_omit_modifiers(monkeypatch): @@ -105,6 +119,11 @@ def test_export_sensor_uses_rider18_formula_without_net_metering(monkeypatch): 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" + assert ( + sensor.extra_state_attributes["current_rate_formula"] + == "(generation 3.0000 + PSCR 1.8770) / 100 = $0.048770/kWh" + ) + assert sensor.extra_state_attributes["current_rate_calculation"]["tax_applied"] is False def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): @@ -116,6 +135,8 @@ def test_export_sensor_ignores_rider18_credit_with_net_metering(monkeypatch): sensor = DteExportRateSensor(coordinator, entry) assert sensor.native_value == pytest.approx(0.0819208) assert sensor.extra_state_attributes["export_rate_source"] == "net_metering" + assert sensor.extra_state_attributes["current_rate_calculation"]["mode"] == "net_metering_export" + assert "net metering export" in sensor.extra_state_attributes["current_rate_formula"] def test_export_sensor_reports_formula_unavailable_without_pscr(monkeypatch): From 47b52a137cb21d97c8aca48fa73653cfb2586d62 Mon Sep 17 00:00:00 2001 From: joshuaterk Date: Sun, 17 May 2026 09:45:38 -0700 Subject: [PATCH 3/3] Handle indexed PSCR rate book sections --- custom_components/dte_rates/pscr_parser.py | 32 ++++++++++++---------- docs/research/rider18-export-rates.md | 2 ++ tests/test_pscr_parser.py | 4 +++ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/custom_components/dte_rates/pscr_parser.py b/custom_components/dte_rates/pscr_parser.py index d8ff1b0..0bef378 100644 --- a/custom_components/dte_rates/pscr_parser.py +++ b/custom_components/dte_rates/pscr_parser.py @@ -19,34 +19,38 @@ def parse_pscr_rates_from_pdf(pdf_bytes: bytes) -> dict[str, Decimal]: 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")) + for section in _power_supply_surcharge_sections(lines): + for line in section: + match = _RATE_ROW_RE.match(line) + if match: + rates[match.group("code").upper()] = Decimal(match.group("pscr")) + if rates: + break 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 +def _power_supply_surcharge_sections(lines: list[str]) -> list[list[str]]: + sections: list[list[str]] = [] 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 + end = _next_delivery_surcharge_index(lines, idx + 1) + sections.append(lines[idx:end]) - if start is None: - return lines + if not sections: + sections.append(lines) + return sections + +def _next_delivery_surcharge_index(lines: list[str], start: int) -> int: end = len(lines) - for idx in range(start + 1, len(lines)): + for idx in range(start, len(lines)): lower = lines[idx].lower() if "c9 surcharges and credits applicable to delivery service" in lower: end = idx break - - return lines[start:end] + return end diff --git a/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md index 1d80b2f..87dc211 100644 --- a/docs/research/rider18-export-rates.md +++ b/docs/research/rider18-export-rates.md @@ -40,6 +40,8 @@ For net metering, keep the existing behavior: export uses the full active import 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. +The MPSC rate book can mention `C8.5 Surcharges and Credits Applicable to Power Supply Service` in the front-matter index before the real tariff table. Parser logic must scan candidate C8.5 sections until it finds tariff rows, otherwise it can stop at the index, see no PSCR rows, and incorrectly log that the rate book does not contain PSCR tariff rows. + 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. diff --git a/tests/test_pscr_parser.py b/tests/test_pscr_parser.py index 62e56ef..ac675ed 100644 --- a/tests/test_pscr_parser.py +++ b/tests/test_pscr_parser.py @@ -6,6 +6,10 @@ SAMPLE_RATE_BOOK_TEXT = """ +C8 SURCHARGES AND CREDITS APPLICABLE TO POWER SUPPLY SERVICE +C8.5 Surcharges and Credits Applicable to Power Supply Service C-65.00 +C9 SURCHARGES AND CREDITS APPLICABLE TO DELIVERY SERVICE +C9.1 Nuclear Surcharge C-66.00 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