Skip to content

Commit b5ea397

Browse files
authored
Merge pull request #4 from javaDevJT/codex/issue-3-rate-modifiers
Add configurable PSCR and tax modifiers
2 parents 216b1ba + 47b52a1 commit b5ea397

19 files changed

Lines changed: 597 additions & 65 deletions

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A Home Assistant custom integration that pulls the official DTE residential elec
1010

1111
- Downloads the live DTE Residential Electric Rate Card PDF.
1212
- Parses plans, periods, windows, and component pricing dynamically.
13-
- Updates on a weekly schedule.
13+
- Updates daily so current-month PSCR changes are picked up promptly.
1414
- Exposes entities for:
1515
- Current import rate (`USD/kWh`)
1616
- Current export rate (`USD/kWh`)
@@ -25,6 +25,7 @@ A Home Assistant custom integration that pulls the official DTE residential elec
2525
- Home Assistant with support for custom integrations.
2626
- Internet access from Home Assistant to:
2727
- `dteenergy.com` (rate card PDF)
28+
- `michigan.gov` (MPSC electric rate book PSCR data)
2829

2930
## Installation
3031

@@ -53,6 +54,9 @@ During setup you choose:
5354

5455
- **Rate plan** from the currently parsed DTE PDF.
5556
- **Net metering enabled** (checkbox).
57+
- **Include PSCR** (enabled by default).
58+
- **Tax rate (%)** (free-form, defaults to `4.0`; set to `0` to omit tax).
59+
- 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.
5660

5761
## Entities Created
5862

@@ -78,6 +82,11 @@ Core rate entities include attributes such as:
7882
- `components`
7983
- `monthly_components`
8084
- `card_effective_date`
85+
- `include_pscr`
86+
- `tax_rate_percent`
87+
- `pscr_cents`
88+
- `current_rate_formula`
89+
- `current_rate_calculation`
8190
- `selected_rate_available`
8291
- `warning` (when selected plan disappears)
8392

@@ -140,8 +149,9 @@ Use `DTE Import Rate` as your current electricity price entity (`USD/kWh`) where
140149

141150
## Notes on Pricing Logic
142151

143-
- Import is full per-kWh total for the active period.
144-
- Export is generation-only unless net metering is enabled.
152+
- Import is full per-kWh total for the active period, plus PSCR when enabled, then the configured tax percentage.
153+
- Rider 18 export is generation plus PSCR when enabled. Tax is not added to Rider 18 export credits.
154+
- Export uses the modified import rate when net metering is enabled.
145155
- Next-rate calculations are schedule-aware and aligned to time boundaries.
146156

147157
## Project Layout

custom_components/dte_rates/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .const import CONF_NET_METERING, CONF_SELECTED_RATE, DOMAIN
1414
from .coordinator import DteRateCoordinator
1515
from .rate_calculator import current_export_rate_cents, current_import_rate_cents, period_display_name
16+
from .settings import entry_include_pscr, entry_tax_rate_percent
1617

1718
PLATFORMS: list[Platform] = [Platform.SENSOR]
1819
SERVICE_REFRESH_RATE_CARD = "refresh_rate_card"
@@ -123,9 +124,13 @@ async def _handle_show_schedule_service(call) -> None:
123124
lines_by_season: dict[str, list[str]] = defaultdict(list)
124125
net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False
125126
pscr_cents = getattr(coordinator.data, "pscr_rates", {}).get(rate.code)
127+
include_pscr = entry_include_pscr(entry) if entry else True
128+
tax_rate_percent = entry_tax_rate_percent(entry) if entry else None
126129
for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)):
127-
import_usd = float(current_import_rate_cents(period) / 100)
128-
export_usd = float(current_export_rate_cents(period, net_metering, pscr_cents) / 100)
130+
import_usd = float(current_import_rate_cents(period, pscr_cents, include_pscr, tax_rate_percent) / 100)
131+
export_usd = float(
132+
current_export_rate_cents(period, net_metering, pscr_cents, include_pscr, tax_rate_percent) / 100
133+
)
129134
lines_by_season[period.season_name].append(
130135
f"{period_display_name(period)}: Import ${import_usd:.4f}/kWh | Export ${export_usd:.4f}/kWh"
131136
)

custom_components/dte_rates/config_flow.py

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,26 @@
44
from homeassistant import config_entries
55
from homeassistant.data_entry_flow import FlowResult
66

7-
from .const import CONF_NET_METERING, CONF_SELECTED_RATE, DOMAIN
7+
from .const import (
8+
CONF_INCLUDE_PSCR,
9+
CONF_NET_METERING,
10+
CONF_SELECTED_RATE,
11+
CONF_TAX_RATE,
12+
DEFAULT_INCLUDE_PSCR,
13+
DEFAULT_TAX_RATE_PERCENT,
14+
DOMAIN,
15+
)
816
from .coordinator import DteRateCoordinator
17+
from .settings import normalize_tax_rate_percent
918

1019

1120
class DteRatesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
1221
VERSION = 1
1322

23+
@staticmethod
24+
def async_get_options_flow(config_entry) -> config_entries.OptionsFlow:
25+
return DteRatesOptionsFlow(config_entry)
26+
1427
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
1528
coordinator = DteRateCoordinator(self.hass)
1629
await coordinator.async_refresh()
@@ -22,31 +35,105 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
2235
for code, rate in coordinator.data.rates.items()
2336
}
2437

38+
errors: dict[str, str] = {}
2539
if user_input is not None:
26-
selected_rate = user_input[CONF_SELECTED_RATE]
27-
return self.async_create_entry(
28-
title=rate_options[selected_rate],
29-
data=user_input,
30-
)
31-
32-
schema = vol.Schema(
33-
{
34-
vol.Required(CONF_SELECTED_RATE): vol.In(rate_options),
35-
vol.Optional(CONF_NET_METERING, default=False): bool,
36-
}
37-
)
40+
try:
41+
data = _normalize_config_input(user_input)
42+
except ValueError:
43+
errors["base"] = "invalid_tax_rate"
44+
else:
45+
selected_rate = data[CONF_SELECTED_RATE]
46+
return self.async_create_entry(
47+
title=rate_options[selected_rate],
48+
data=data,
49+
)
50+
51+
schema = _user_schema(rate_options)
3852
return self.async_show_form(
3953
step_id="user",
4054
data_schema=schema,
55+
errors=errors,
4156
description_placeholders={
4257
"rider18_status": _rider18_status(coordinator.data),
58+
"tax_note": _tax_note(),
4359
},
4460
)
4561

4662

63+
class DteRatesOptionsFlow(config_entries.OptionsFlow):
64+
def __init__(self, config_entry) -> None:
65+
self.config_entry = config_entry
66+
67+
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
68+
errors: dict[str, str] = {}
69+
if user_input is not None:
70+
try:
71+
data = _normalize_options_input(user_input)
72+
except ValueError:
73+
errors["base"] = "invalid_tax_rate"
74+
else:
75+
return self.async_create_entry(title="", data=data)
76+
77+
return self.async_show_form(
78+
step_id="init",
79+
data_schema=_options_schema(self.config_entry),
80+
errors=errors,
81+
description_placeholders={"tax_note": _tax_note()},
82+
)
83+
84+
85+
def _normalize_config_input(user_input: dict) -> dict:
86+
data = dict(user_input)
87+
data.setdefault(CONF_INCLUDE_PSCR, DEFAULT_INCLUDE_PSCR)
88+
data.setdefault(CONF_TAX_RATE, DEFAULT_TAX_RATE_PERCENT)
89+
data[CONF_INCLUDE_PSCR] = bool(data[CONF_INCLUDE_PSCR])
90+
data[CONF_TAX_RATE] = normalize_tax_rate_percent(data[CONF_TAX_RATE])
91+
return data
92+
93+
94+
def _normalize_options_input(user_input: dict) -> dict:
95+
data = {
96+
CONF_INCLUDE_PSCR: bool(user_input.get(CONF_INCLUDE_PSCR, DEFAULT_INCLUDE_PSCR)),
97+
CONF_TAX_RATE: normalize_tax_rate_percent(user_input.get(CONF_TAX_RATE, DEFAULT_TAX_RATE_PERCENT)),
98+
}
99+
return data
100+
101+
102+
def _user_schema(rate_options: dict[str, str]) -> vol.Schema:
103+
return vol.Schema(
104+
{
105+
vol.Required(CONF_SELECTED_RATE): vol.In(rate_options),
106+
vol.Optional(CONF_NET_METERING, default=False): bool,
107+
vol.Optional(CONF_INCLUDE_PSCR, default=DEFAULT_INCLUDE_PSCR): bool,
108+
vol.Optional(CONF_TAX_RATE, default=DEFAULT_TAX_RATE_PERCENT): str,
109+
}
110+
)
111+
112+
113+
def _options_schema(config_entry) -> vol.Schema:
114+
options = getattr(config_entry, "options", None) or {}
115+
data = getattr(config_entry, "data", None) or {}
116+
include_pscr = options.get(CONF_INCLUDE_PSCR, data.get(CONF_INCLUDE_PSCR, DEFAULT_INCLUDE_PSCR))
117+
tax_rate = options.get(CONF_TAX_RATE, data.get(CONF_TAX_RATE, DEFAULT_TAX_RATE_PERCENT))
118+
return vol.Schema(
119+
{
120+
vol.Optional(CONF_INCLUDE_PSCR, default=include_pscr): bool,
121+
vol.Optional(CONF_TAX_RATE, default=tax_rate): str,
122+
}
123+
)
124+
125+
47126
def _rider18_status(rate_card) -> str:
48127
count = len(getattr(rate_card, "pscr_rates", {}))
49128
if count:
50129
suffix = "tariff" if count == 1 else "tariffs"
51130
return f"Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for {count} {suffix}."
52131
return "Rider 18 export credits use parsed generation rates plus MPSC PSCR when the selected tariff has a PSCR factor."
132+
133+
134+
def _tax_note() -> str:
135+
return (
136+
"Default tax is Michigan's 4.0% residential electric sales tax. "
137+
"Detroit residents may incur an additional 5.0% City Utility Users' Tax. "
138+
"Set tax rate to 0 to omit taxes from rate calculations."
139+
)

custom_components/dte_rates/const.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111
"rate-books/electric/dte/dtee1cur.pdf"
1212
)
1313

14-
UPDATE_INTERVAL = timedelta(days=7)
14+
UPDATE_INTERVAL = timedelta(days=1)
1515

1616
CONF_SELECTED_RATE = "selected_rate"
1717
CONF_NET_METERING = "net_metering"
18+
CONF_INCLUDE_PSCR = "include_pscr"
19+
CONF_TAX_RATE = "tax_rate"
20+
21+
DEFAULT_INCLUDE_PSCR = True
22+
DEFAULT_TAX_RATE_PERCENT = "4.0"
1823

1924
ATTR_RATE_CODE = "rate_code"
2025
ATTR_RATE_NAME = "rate_name"
@@ -30,10 +35,14 @@
3035
ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available"
3136
ATTR_EXPORT_RATE_SOURCE = "export_rate_source"
3237
ATTR_EXPORT_RATE_WARNING = "export_rate_warning"
38+
ATTR_INCLUDE_PSCR = "include_pscr"
39+
ATTR_TAX_RATE_PERCENT = "tax_rate_percent"
3340
ATTR_CARD_EFFECTIVE_DATE = "card_effective_date"
3441
ATTR_SELECTED_RATE_AVAILABLE = "selected_rate_available"
3542
ATTR_WARNING = "warning"
3643
ATTR_CURRENT_RATE_NAME = "current_rate_name"
44+
ATTR_CURRENT_RATE_CALCULATION = "current_rate_calculation"
45+
ATTR_CURRENT_RATE_FORMULA = "current_rate_formula"
3746
ATTR_NEXT_RATE_CHANGE = "next_rate_change"
3847
ATTR_NEXT_RATE_NAME = "next_rate_name"
3948
ATTR_NEXT_RATE_VALUE = "next_rate_value"

custom_components/dte_rates/pscr_parser.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,38 @@ def parse_pscr_rates_from_pdf(pdf_bytes: bytes) -> dict[str, Decimal]:
1919
text = "\n".join(page.extract_text() or "" for page in reader.pages)
2020
lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()]
2121

22-
section = _power_supply_surcharge_lines(lines)
2322
rates: dict[str, Decimal] = {}
24-
for line in section:
25-
match = _RATE_ROW_RE.match(line)
26-
if match:
27-
rates[match.group("code").upper()] = Decimal(match.group("pscr"))
23+
for section in _power_supply_surcharge_sections(lines):
24+
for line in section:
25+
match = _RATE_ROW_RE.match(line)
26+
if match:
27+
rates[match.group("code").upper()] = Decimal(match.group("pscr"))
28+
if rates:
29+
break
2830

2931
if not rates:
3032
raise ValueError("MPSC DTE rate book does not contain PSCR tariff rows")
3133
return rates
3234

3335

34-
def _power_supply_surcharge_lines(lines: list[str]) -> list[str]:
35-
start = None
36+
def _power_supply_surcharge_sections(lines: list[str]) -> list[list[str]]:
37+
sections: list[list[str]] = []
3638
for idx, line in enumerate(lines):
3739
lower = line.lower()
3840
if "c8.5 surcharges and credits applicable to power supply service" in lower:
39-
start = idx
40-
break
41+
end = _next_delivery_surcharge_index(lines, idx + 1)
42+
sections.append(lines[idx:end])
4143

42-
if start is None:
43-
return lines
44+
if not sections:
45+
sections.append(lines)
46+
return sections
4447

48+
49+
def _next_delivery_surcharge_index(lines: list[str], start: int) -> int:
4550
end = len(lines)
46-
for idx in range(start + 1, len(lines)):
51+
for idx in range(start, len(lines)):
4752
lower = lines[idx].lower()
4853
if "c9 surcharges and credits applicable to delivery service" in lower:
4954
end = idx
5055
break
51-
52-
return lines[start:end]
56+
return end

custom_components/dte_rates/rate_calculator.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,16 @@ def get_active_period(rate: RatePlan, now: datetime) -> SeasonalPeriodRate | Non
6161
return None
6262

6363

64-
def current_import_rate_cents(period: SeasonalPeriodRate) -> Decimal:
65-
return period.components.per_kwh_total
64+
def current_import_rate_cents(
65+
period: SeasonalPeriodRate,
66+
pscr_cents: Decimal | None = None,
67+
include_pscr: bool = False,
68+
tax_rate_percent: Decimal | None = None,
69+
) -> Decimal:
70+
total = period.components.per_kwh_total
71+
if include_pscr and pscr_cents is not None:
72+
total += pscr_cents
73+
return _apply_tax(total, tax_rate_percent)
6674

6775

6876
def _component_total(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> Decimal:
@@ -77,23 +85,40 @@ def _has_component(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> bool
7785
return any(any(marker in key for marker in markers) for key in period.components.per_kwh)
7886

7987

80-
def rider18_export_rate_cents(period: SeasonalPeriodRate, pscr_cents: Decimal | None = None) -> Decimal:
81-
return _component_total(period, GENERATION_COMPONENT_MARKERS) + (pscr_cents or Decimal("0"))
88+
def rider18_export_rate_cents(
89+
period: SeasonalPeriodRate,
90+
pscr_cents: Decimal | None = None,
91+
include_pscr: bool = True,
92+
) -> Decimal:
93+
pscr = pscr_cents if include_pscr and pscr_cents is not None else Decimal("0")
94+
return _component_total(period, GENERATION_COMPONENT_MARKERS) + pscr
8295

8396

84-
def rider18_export_formula_available(period: SeasonalPeriodRate, pscr_cents: Decimal | None = None) -> bool:
85-
return _has_component(period, GENERATION_COMPONENT_MARKERS) and pscr_cents is not None
97+
def rider18_export_formula_available(
98+
period: SeasonalPeriodRate,
99+
pscr_cents: Decimal | None = None,
100+
include_pscr: bool = True,
101+
) -> bool:
102+
return _has_component(period, GENERATION_COMPONENT_MARKERS) and (not include_pscr or pscr_cents is not None)
86103

87104

88105
def current_export_rate_cents(
89106
period: SeasonalPeriodRate,
90107
net_metering: bool,
91108
pscr_cents: Decimal | None = None,
109+
include_pscr: bool = True,
110+
tax_rate_percent: Decimal | None = None,
92111
) -> Decimal:
93112
if net_metering:
94-
return period.components.per_kwh_total
113+
return current_import_rate_cents(period, pscr_cents, include_pscr, tax_rate_percent)
114+
115+
return rider18_export_rate_cents(period, pscr_cents, include_pscr)
116+
95117

96-
return rider18_export_rate_cents(period, pscr_cents)
118+
def _apply_tax(value: Decimal, tax_rate_percent: Decimal | None) -> Decimal:
119+
if tax_rate_percent is None:
120+
return value
121+
return value * (Decimal("1") + tax_rate_percent / Decimal("100"))
97122

98123

99124
def period_display_name(period: SeasonalPeriodRate) -> str:

0 commit comments

Comments
 (0)