Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions custom_components/dte_rates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
)
Expand Down
113 changes: 100 additions & 13 deletions custom_components/dte_rates/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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."
)
11 changes: 10 additions & 1 deletion custom_components/dte_rates/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
32 changes: 18 additions & 14 deletions custom_components/dte_rates/pscr_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 33 additions & 8 deletions custom_components/dte_rates/rate_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading