diff --git a/README.md b/README.md index da0294a..f2b9530 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,9 @@ 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). +- 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 @@ -78,6 +82,11 @@ Core rate entities include attributes such as: - `components` - `monthly_components` - `card_effective_date` +- `include_pscr` +- `tax_rate_percent` +- `pscr_cents` +- `current_rate_formula` +- `current_rate_calculation` - `selected_rate_available` - `warning` (when selected plan disappears) @@ -140,8 +149,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..a62d13b 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,105 @@ 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. " + "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 a42cd55..42580e2 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,10 +35,14 @@ 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" 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/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/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..e43405b 100644 --- a/custom_components/dte_rates/sensor.py +++ b/custom_components/dte_rates/sensor.py @@ -19,9 +19,12 @@ 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, + ATTR_INCLUDE_PSCR, ATTR_NEXT_RATE_CHANGE, ATTR_NEXT_RATE_NAME, ATTR_NEXT_RATE_VALUE, @@ -37,6 +40,7 @@ ATTR_SEASON, ATTR_SELECTED_RATE_AVAILABLE, ATTR_SOURCE_URL, + ATTR_TAX_RATE_PERCENT, ATTR_WARNING, CONF_NET_METERING, CONF_SELECTED_RATE, @@ -44,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, @@ -51,6 +56,7 @@ period_display_name, rider18_export_formula_available, ) +from .settings import entry_include_pscr, entry_tax_rate_percent async def async_setup_entry( @@ -118,30 +124,120 @@ 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 " "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: @@ -156,6 +252,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: @@ -191,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: @@ -222,8 +323,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 +389,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 +413,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 @@ -323,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 @@ -349,7 +457,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 +498,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 +524,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..f4e59bc --- /dev/null +++ b/docs/research/issue-3-rate-modifiers.md @@ -0,0 +1,33 @@ +# 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. +- 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 + +- 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 +- 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/docs/research/rider18-export-rates.md b/docs/research/rider18-export-rates.md index 2539093..87dc211 100644 --- a/docs/research/rider18-export-rates.md +++ b/docs/research/rider18-export-rates.md @@ -40,8 +40,12 @@ 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. - `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..550ca9a 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,29 @@ 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"] + assert "Detroit residents may incur an additional 5.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_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 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..caade61 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,57 @@ 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 + 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): + 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): @@ -75,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): @@ -84,8 +133,10 @@ 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" + 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): @@ -162,7 +213,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 +256,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):