Skip to content

Commit 216b1ba

Browse files
authored
Merge pull request #2 from javaDevJT/enhancement/rider-18-export-rates
[codex] Use tariff-specific MPSC PSCR factors
2 parents a32d2d7 + d0da093 commit 216b1ba

17 files changed

Lines changed: 414 additions & 27 deletions

custom_components/dte_rates/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from homeassistant.exceptions import ConfigEntryNotReady
1111
from homeassistant.components import persistent_notification
1212

13-
from .const import DOMAIN
13+
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
1616

@@ -110,20 +110,22 @@ async def _handle_show_schedule_service(call) -> None:
110110
if coordinator is None:
111111
return
112112

113+
entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None)
113114
rate_code = call.data.get("rate_code")
114115
if rate_code:
115116
rate = coordinator.data.rates.get(rate_code)
116117
else:
117-
entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None)
118-
rate = coordinator.data.rates.get(entry.data.get("selected_rate")) if entry else None
118+
rate = coordinator.data.rates.get(entry.data.get(CONF_SELECTED_RATE)) if entry else None
119119

120120
if rate is None:
121121
return
122122

123123
lines_by_season: dict[str, list[str]] = defaultdict(list)
124+
net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False
125+
pscr_cents = getattr(coordinator.data, "pscr_rates", {}).get(rate.code)
124126
for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)):
125127
import_usd = float(current_import_rate_cents(period) / 100)
126-
export_usd = float(current_export_rate_cents(period, False) / 100)
128+
export_usd = float(current_export_rate_cents(period, net_metering, pscr_cents) / 100)
127129
lines_by_season[period.season_name].append(
128130
f"{period_display_name(period)}: Import ${import_usd:.4f}/kWh | Export ${export_usd:.4f}/kWh"
129131
)

custom_components/dte_rates/config_flow.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,18 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
3535
vol.Optional(CONF_NET_METERING, default=False): bool,
3636
}
3737
)
38-
return self.async_show_form(step_id="user", data_schema=schema)
38+
return self.async_show_form(
39+
step_id="user",
40+
data_schema=schema,
41+
description_placeholders={
42+
"rider18_status": _rider18_status(coordinator.data),
43+
},
44+
)
45+
46+
47+
def _rider18_status(rate_card) -> str:
48+
count = len(getattr(rate_card, "pscr_rates", {}))
49+
if count:
50+
suffix = "tariff" if count == 1 else "tariffs"
51+
return f"Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for {count} {suffix}."
52+
return "Rider 18 export credits use parsed generation rates plus MPSC PSCR when the selected tariff has a PSCR factor."

custom_components/dte_rates/const.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
"residential/Service-Request/pricing/residential-pricing-options/"
77
"ResidentialElectricRateCard.pdf"
88
)
9+
PSCR_RATE_BOOK_URL = (
10+
"https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/"
11+
"rate-books/electric/dte/dtee1cur.pdf"
12+
)
913

1014
UPDATE_INTERVAL = timedelta(days=7)
1115

@@ -19,6 +23,13 @@
1923
ATTR_COMPONENTS = "components"
2024
ATTR_MONTHLY_COMPONENTS = "monthly_components"
2125
ATTR_SOURCE_URL = "source_url"
26+
ATTR_PSCR_CENTS = "pscr_cents"
27+
ATTR_PSCR_RATE_CODE = "pscr_rate_code"
28+
ATTR_PSCR_RATES = "pscr_rates"
29+
ATTR_PSCR_SOURCE_URL = "pscr_source_url"
30+
ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available"
31+
ATTR_EXPORT_RATE_SOURCE = "export_rate_source"
32+
ATTR_EXPORT_RATE_WARNING = "export_rate_warning"
2233
ATTR_CARD_EFFECTIVE_DATE = "card_effective_date"
2334
ATTR_SELECTED_RATE_AVAILABLE = "selected_rate_available"
2435
ATTR_WARNING = "warning"

custom_components/dte_rates/coordinator.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
from homeassistant.helpers.aiohttp_client import async_get_clientsession
88
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
99

10-
from .const import RATE_CARD_URL, UPDATE_INTERVAL
10+
from .const import PSCR_RATE_BOOK_URL, RATE_CARD_URL, UPDATE_INTERVAL
1111
from .models import ParsedRateCard
1212
from .pdf_parser import parse_rate_card_pdf
13+
from .pscr_parser import parse_pscr_rates_from_pdf
1314

1415
_LOGGER = logging.getLogger(__name__)
1516

@@ -33,11 +34,46 @@ async def _async_update_data(self) -> ParsedRateCard:
3334
except Exception as err:
3435
raise UpdateFailed(f"Failed downloading DTE rate card: {err}") from err
3536

37+
pscr_bytes: bytes | None = None
3638
try:
37-
return await self.hass.async_add_executor_job(
39+
async with session.get(
40+
PSCR_RATE_BOOK_URL,
41+
headers={
42+
"Accept": "application/pdf,*/*",
43+
"User-Agent": "DTE-Rates-for-Home-Assistant/1.0",
44+
},
45+
timeout=60,
46+
) as resp:
47+
resp.raise_for_status()
48+
pscr_bytes = await resp.read()
49+
except Exception as err:
50+
_LOGGER.warning(
51+
"Failed downloading MPSC DTE rate book; export rates will omit PSCR: %s",
52+
err,
53+
)
54+
55+
try:
56+
parsed = await self.hass.async_add_executor_job(
3857
parse_rate_card_pdf,
3958
pdf_bytes,
4059
RATE_CARD_URL,
4160
)
4261
except Exception as err:
4362
raise UpdateFailed(f"Failed parsing DTE rate card: {err}") from err
63+
64+
if pscr_bytes is None:
65+
return parsed
66+
67+
try:
68+
parsed.pscr_rates = await self.hass.async_add_executor_job(
69+
parse_pscr_rates_from_pdf,
70+
pscr_bytes,
71+
)
72+
parsed.pscr_source_url = PSCR_RATE_BOOK_URL
73+
except Exception as err:
74+
_LOGGER.warning(
75+
"Failed parsing MPSC DTE rate book PSCR; export rates will omit PSCR: %s",
76+
err,
77+
)
78+
79+
return parsed

custom_components/dte_rates/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,5 @@ class ParsedRateCard:
5050
effective_date: str | None
5151
rates: dict[str, RatePlan]
5252
raw_text_hash: str
53+
pscr_rates: dict[str, Decimal] = field(default_factory=dict)
54+
pscr_source_url: str | None = None
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
3+
from decimal import Decimal
4+
import io
5+
import re
6+
7+
from pypdf import PdfReader
8+
9+
10+
_RATE_ROW_RE = re.compile(
11+
r"^\s*(?P<code>[A-Z]\d+(?:\.\d+)?)\s+.+?\s+(?P<pscr>-?\d+\.\d+)\s+\d+\.\d+",
12+
re.IGNORECASE,
13+
)
14+
15+
16+
def parse_pscr_rates_from_pdf(pdf_bytes: bytes) -> dict[str, Decimal]:
17+
"""Extract current PSCR values by tariff code from the MPSC DTE electric rate book."""
18+
reader = PdfReader(io.BytesIO(pdf_bytes))
19+
text = "\n".join(page.extract_text() or "" for page in reader.pages)
20+
lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()]
21+
22+
section = _power_supply_surcharge_lines(lines)
23+
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"))
28+
29+
if not rates:
30+
raise ValueError("MPSC DTE rate book does not contain PSCR tariff rows")
31+
return rates
32+
33+
34+
def _power_supply_surcharge_lines(lines: list[str]) -> list[str]:
35+
start = None
36+
for idx, line in enumerate(lines):
37+
lower = line.lower()
38+
if "c8.5 surcharges and credits applicable to power supply service" in lower:
39+
start = idx
40+
break
41+
42+
if start is None:
43+
return lines
44+
45+
end = len(lines)
46+
for idx in range(start + 1, len(lines)):
47+
lower = lines[idx].lower()
48+
if "c9 surcharges and credits applicable to delivery service" in lower:
49+
end = idx
50+
break
51+
52+
return lines[start:end]

custom_components/dte_rates/rate_calculator.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,35 @@ def current_import_rate_cents(period: SeasonalPeriodRate) -> Decimal:
6565
return period.components.per_kwh_total
6666

6767

68-
def current_export_rate_cents(period: SeasonalPeriodRate, net_metering: bool) -> Decimal:
68+
def _component_total(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> Decimal:
69+
total = Decimal("0")
70+
for key, value in period.components.per_kwh.items():
71+
if any(marker in key for marker in markers):
72+
total += value
73+
return total
74+
75+
76+
def _has_component(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> bool:
77+
return any(any(marker in key for marker in markers) for key in period.components.per_kwh)
78+
79+
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"))
82+
83+
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
86+
87+
88+
def current_export_rate_cents(
89+
period: SeasonalPeriodRate,
90+
net_metering: bool,
91+
pscr_cents: Decimal | None = None,
92+
) -> Decimal:
6993
if net_metering:
7094
return period.components.per_kwh_total
7195

72-
generation_only = Decimal("0")
73-
for key, value in period.components.per_kwh.items():
74-
if any(marker in key for marker in GENERATION_COMPONENT_MARKERS):
75-
generation_only += value
76-
return generation_only
96+
return rider18_export_rate_cents(period, pscr_cents)
7797

7898

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

custom_components/dte_rates/sensor.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections import defaultdict
4+
from decimal import Decimal
45

56
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
67
from homeassistant.components import persistent_notification
@@ -19,11 +20,18 @@
1920
ATTR_MONTHLY_COMPONENTS,
2021
ATTR_PERIOD,
2122
ATTR_CURRENT_RATE_NAME,
23+
ATTR_EXPORT_RATE_SOURCE,
24+
ATTR_EXPORT_RATE_WARNING,
2225
ATTR_NEXT_RATE_CHANGE,
2326
ATTR_NEXT_RATE_NAME,
2427
ATTR_NEXT_RATE_VALUE,
28+
ATTR_PSCR_CENTS,
29+
ATTR_PSCR_RATE_CODE,
30+
ATTR_PSCR_RATES,
31+
ATTR_PSCR_SOURCE_URL,
2532
ATTR_RATE_CODE,
2633
ATTR_RATE_NAME,
34+
ATTR_RIDER18_EXPORT_AVAILABLE,
2735
ATTR_SCHEDULE_BY_SEASON,
2836
ATTR_SCHEDULE_TEXT,
2937
ATTR_SEASON,
@@ -41,6 +49,7 @@
4149
get_active_period,
4250
get_next_rate_change,
4351
period_display_name,
52+
rider18_export_formula_available,
4453
)
4554

4655

@@ -103,6 +112,36 @@ def _active_period(self) -> SeasonalPeriodRate | None:
103112
return None
104113
return get_active_period(rate, dt_util.now())
105114

115+
def _pscr_cents_for_rate(self, rate: RatePlan | None = None) -> Decimal | None:
116+
target_rate = rate or self._selected_rate()
117+
if target_rate is None:
118+
return None
119+
return getattr(self.coordinator.data, "pscr_rates", {}).get(target_rate.code)
120+
121+
def _export_rate_cents(self, period: SeasonalPeriodRate):
122+
return current_export_rate_cents(
123+
period,
124+
self._entry.data.get(CONF_NET_METERING, False),
125+
self._pscr_cents_for_rate(),
126+
)
127+
128+
def _export_rate_source(self, period: SeasonalPeriodRate) -> str:
129+
if self._entry.data.get(CONF_NET_METERING, False):
130+
return "net_metering"
131+
if rider18_export_formula_available(period, self._pscr_cents_for_rate()):
132+
return "rider18_formula"
133+
return "rider18_formula_incomplete"
134+
135+
def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None:
136+
if self._entry.data.get(CONF_NET_METERING, False):
137+
return None
138+
if rider18_export_formula_available(period, self._pscr_cents_for_rate()):
139+
return None
140+
return (
141+
"Rider 18 formula is missing a generation component or PSCR value "
142+
"for the active period; using the available formula components only."
143+
)
144+
106145
def _warning(self) -> str | None:
107146
selected = self._entry.data[CONF_SELECTED_RATE]
108147
if selected not in self.coordinator.data.rates:
@@ -118,6 +157,16 @@ def _base_attributes(self) -> dict:
118157
ATTR_SOURCE_URL: self.coordinator.data.source_url,
119158
ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date,
120159
}
160+
pscr_cents = self._pscr_cents_for_rate()
161+
if pscr_cents is not None:
162+
attrs[ATTR_PSCR_CENTS] = float(pscr_cents)
163+
rate = self._selected_rate()
164+
if rate is not None:
165+
attrs[ATTR_PSCR_RATE_CODE] = rate.code
166+
if self.coordinator.data.pscr_rates:
167+
attrs[ATTR_PSCR_RATES] = {code: float(value) for code, value in self.coordinator.data.pscr_rates.items()}
168+
if self.coordinator.data.pscr_source_url:
169+
attrs[ATTR_PSCR_SOURCE_URL] = self.coordinator.data.pscr_source_url
121170

122171
rate = self._selected_rate()
123172
period = self._active_period()
@@ -260,13 +309,19 @@ def native_value(self) -> float | None:
260309
def extra_state_attributes(self) -> dict:
261310
attrs = self._base_attributes()
262311
attrs[CONF_NET_METERING] = self._entry.data.get(CONF_NET_METERING, False)
312+
period = self._active_period()
313+
if period is not None:
314+
attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period)
315+
attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents_for_rate())
316+
warning = self._export_rate_warning(period)
317+
if warning is not None:
318+
attrs[ATTR_EXPORT_RATE_WARNING] = warning
263319
return attrs
264320

265321
def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None:
266322
if period is None:
267323
return None
268-
cents = current_export_rate_cents(period, self._entry.data.get(CONF_NET_METERING, False))
269-
return float(cents / 100)
324+
return float(self._export_rate_cents(period) / 100)
270325

271326

272327
class DteCurrentRateNameSensor(_DteBaseRateSensor):
@@ -328,7 +383,6 @@ def extra_state_attributes(self) -> dict:
328383

329384
def _schedule_rows(self, rate: RatePlan) -> list[dict]:
330385
rows: list[dict] = []
331-
net_metering = self._entry.data.get(CONF_NET_METERING, False)
332386
for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)):
333387
rows.append(
334388
{
@@ -337,7 +391,7 @@ def _schedule_rows(self, rate: RatePlan) -> list[dict]:
337391
"name": period_display_name(period),
338392
"time_window": self._window_summary(period),
339393
"import_usd_per_kwh": round(float(current_import_rate_cents(period) / 100), 6),
340-
"export_usd_per_kwh": round(float(current_export_rate_cents(period, net_metering) / 100), 6),
394+
"export_usd_per_kwh": round(float(self._export_rate_cents(period) / 100), 6),
341395
}
342396
)
343397
return rows

custom_components/dte_rates/strings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"step": {
44
"user": {
55
"title": "DTE Residential Rates",
6-
"description": "Choose your DTE residential electric pricing plan.",
6+
"description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}",
77
"data": {
88
"selected_rate": "Rate plan",
99
"net_metering": "Net metering enabled"

custom_components/dte_rates/translations/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"step": {
44
"user": {
55
"title": "DTE Residential Rates",
6-
"description": "Choose your DTE residential electric pricing plan.",
6+
"description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}",
77
"data": {
88
"selected_rate": "Rate plan",
99
"net_metering": "Net metering enabled"

0 commit comments

Comments
 (0)