Skip to content

Commit d0da093

Browse files
committed
Use tariff-specific MPSC PSCR factors
1 parent ab13780 commit d0da093

11 files changed

Lines changed: 100 additions & 37 deletions

File tree

custom_components/dte_rates/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ async def _handle_show_schedule_service(call) -> None:
122122

123123
lines_by_season: dict[str, list[str]] = defaultdict(list)
124124
net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False
125-
pscr_cents = getattr(coordinator.data, "pscr_cents", None)
125+
pscr_cents = getattr(coordinator.data, "pscr_rates", {}).get(rate.code)
126126
for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)):
127127
import_usd = float(current_import_rate_cents(period) / 100)
128128
export_usd = float(current_export_rate_cents(period, net_metering, pscr_cents) / 100)

custom_components/dte_rates/config_flow.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,9 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
4444
)
4545

4646

47-
def _rider18_status(_rate_card) -> str:
48-
return "Rider 18 export credits use parsed generation rates plus PSCR when the PSCR value is available."
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
ATTR_MONTHLY_COMPONENTS = "monthly_components"
2525
ATTR_SOURCE_URL = "source_url"
2626
ATTR_PSCR_CENTS = "pscr_cents"
27+
ATTR_PSCR_RATE_CODE = "pscr_rate_code"
28+
ATTR_PSCR_RATES = "pscr_rates"
2729
ATTR_PSCR_SOURCE_URL = "pscr_source_url"
2830
ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available"
2931
ATTR_EXPORT_RATE_SOURCE = "export_rate_source"

custom_components/dte_rates/coordinator.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
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_cents_from_pdf
13+
from .pscr_parser import parse_pscr_rates_from_pdf
1414

1515
_LOGGER = logging.getLogger(__name__)
1616

@@ -65,8 +65,8 @@ async def _async_update_data(self) -> ParsedRateCard:
6565
return parsed
6666

6767
try:
68-
parsed.pscr_cents = await self.hass.async_add_executor_job(
69-
parse_pscr_cents_from_pdf,
68+
parsed.pscr_rates = await self.hass.async_add_executor_job(
69+
parse_pscr_rates_from_pdf,
7070
pscr_bytes,
7171
)
7272
parsed.pscr_source_url = PSCR_RATE_BOOK_URL

custom_components/dte_rates/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,5 @@ class ParsedRateCard:
5050
effective_date: str | None
5151
rates: dict[str, RatePlan]
5252
raw_text_hash: str
53-
pscr_cents: Decimal | None = None
53+
pscr_rates: dict[str, Decimal] = field(default_factory=dict)
5454
pscr_source_url: str | None = None

custom_components/dte_rates/pscr_parser.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,46 @@
77
from pypdf import PdfReader
88

99

10-
_D111_PSCR_RE = re.compile(
11-
r"\bD1\.11\s+Standard\s+TOU\s+(?P<pscr>\d+\.\d+)",
10+
_RATE_ROW_RE = re.compile(
11+
r"^\s*(?P<code>[A-Z]\d+(?:\.\d+)?)\s+.+?\s+(?P<pscr>-?\d+\.\d+)\s+\d+\.\d+",
1212
re.IGNORECASE,
1313
)
1414

1515

16-
def parse_pscr_cents_from_pdf(pdf_bytes: bytes) -> Decimal:
17-
"""Extract the current PSCR value from the MPSC DTE electric rate book."""
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."""
1818
reader = PdfReader(io.BytesIO(pdf_bytes))
1919
text = "\n".join(page.extract_text() or "" for page in reader.pages)
20-
normalized = re.sub(r"\s+", " ", text)
21-
22-
match = _D111_PSCR_RE.search(normalized)
23-
if match:
24-
return Decimal(match.group("pscr"))
25-
26-
raise ValueError("MPSC DTE rate book does not contain a D1.11 PSCR value")
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/sensor.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
ATTR_NEXT_RATE_NAME,
2727
ATTR_NEXT_RATE_VALUE,
2828
ATTR_PSCR_CENTS,
29+
ATTR_PSCR_RATE_CODE,
30+
ATTR_PSCR_RATES,
2931
ATTR_PSCR_SOURCE_URL,
3032
ATTR_RATE_CODE,
3133
ATTR_RATE_NAME,
@@ -110,27 +112,30 @@ def _active_period(self) -> SeasonalPeriodRate | None:
110112
return None
111113
return get_active_period(rate, dt_util.now())
112114

113-
def _pscr_cents(self) -> Decimal | None:
114-
return getattr(self.coordinator.data, "pscr_cents", None)
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)
115120

116121
def _export_rate_cents(self, period: SeasonalPeriodRate):
117122
return current_export_rate_cents(
118123
period,
119124
self._entry.data.get(CONF_NET_METERING, False),
120-
self._pscr_cents(),
125+
self._pscr_cents_for_rate(),
121126
)
122127

123128
def _export_rate_source(self, period: SeasonalPeriodRate) -> str:
124129
if self._entry.data.get(CONF_NET_METERING, False):
125130
return "net_metering"
126-
if rider18_export_formula_available(period, self._pscr_cents()):
131+
if rider18_export_formula_available(period, self._pscr_cents_for_rate()):
127132
return "rider18_formula"
128133
return "rider18_formula_incomplete"
129134

130135
def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None:
131136
if self._entry.data.get(CONF_NET_METERING, False):
132137
return None
133-
if rider18_export_formula_available(period, self._pscr_cents()):
138+
if rider18_export_formula_available(period, self._pscr_cents_for_rate()):
134139
return None
135140
return (
136141
"Rider 18 formula is missing a generation component or PSCR value "
@@ -152,8 +157,14 @@ def _base_attributes(self) -> dict:
152157
ATTR_SOURCE_URL: self.coordinator.data.source_url,
153158
ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date,
154159
}
155-
if self.coordinator.data.pscr_cents is not None:
156-
attrs[ATTR_PSCR_CENTS] = float(self.coordinator.data.pscr_cents)
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()}
157168
if self.coordinator.data.pscr_source_url:
158169
attrs[ATTR_PSCR_SOURCE_URL] = self.coordinator.data.pscr_source_url
159170

@@ -301,7 +312,7 @@ def extra_state_attributes(self) -> dict:
301312
period = self._active_period()
302313
if period is not None:
303314
attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period)
304-
attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents())
315+
attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents_for_rate())
305316
warning = self._export_rate_warning(period)
306317
if warning is not None:
307318
attrs[ATTR_EXPORT_RATE_WARNING] = warning

docs/research/rider18-export-rates.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44

55
- Enhancement request: calculate Rider 18 export credits to match DTE's Rider 18 calculator.
66
- Residential rate card: source of the active generation components for each rate period.
7-
- MPSC DTE electric rate book: source of the current PSCR scalar.
7+
- MPSC DTE electric rate book: source of current PSCR factors by tariff code.
88

99
## Implementation Decision
1010

11-
Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. PSCR is parsed dynamically from the MPSC DTE electric rate book PDF at `https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf`, because the residential rate card PDF does not include that scalar.
11+
Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. PSCR factors are parsed dynamically by tariff code from the MPSC DTE electric rate book PDF at `https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf`, because the residential rate card PDF does not include those factors.
1212

1313
Formula:
1414

@@ -38,7 +38,9 @@ For net metering, keep the existing behavior: export uses the full active import
3838

3939
## UI/Entity Status
4040

41-
The setup flow explains that Rider 18 export credits use parsed generation plus PSCR when the PSCR value is available. Export entities expose whether the active period has enough data for the full formula:
41+
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.
42+
43+
Export entities expose whether the active period has enough data for the full formula:
4244

4345
- `export_rate_source: rider18_formula` when generation components and PSCR are present.
4446
- `export_rate_source: rider18_formula_incomplete` when one side of the formula is missing.

tests/test_config_flow.py

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

3+
from decimal import Decimal
34
from unittest.mock import MagicMock
45

56
import pytest
@@ -23,7 +24,7 @@ async def _refresh(self):
2324
"D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]),
2425
},
2526
raw_text_hash="abc",
26-
pscr_cents=None,
27+
pscr_rates={},
2728
)
2829

2930
monkeypatch.setattr(
@@ -58,6 +59,10 @@ async def _refresh(self):
5859
"D1.13": RatePlan(code="D1.13", name="Overnight", periods=[]),
5960
},
6061
raw_text_hash="abc",
62+
pscr_rates={
63+
"D1.11": Decimal("1.877"),
64+
"D1.13": Decimal("1.877"),
65+
},
6166
)
6267

6368
monkeypatch.setattr(
@@ -70,5 +75,5 @@ async def _refresh(self):
7075
assert result["type"] == "form"
7176
assert (
7277
result["description_placeholders"]["rider18_status"]
73-
== "Rider 18 export credits use parsed generation rates plus PSCR when the PSCR value is available."
78+
== "Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for 2 tariffs."
7479
)

tests/test_pscr_parser.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22

33
from decimal import Decimal
44

5-
from custom_components.dte_rates.pscr_parser import parse_pscr_cents_from_pdf
5+
from custom_components.dte_rates.pscr_parser import parse_pscr_rates_from_pdf
66

77

88
SAMPLE_RATE_BOOK_TEXT = """
99
C8.5 SURCHARGES AND CREDITS APPLICABLE TO POWER SUPPLY SERVICE
1010
Rate Schedule Description PSCR Factor Other Charges
11+
D1 Non Transmitting Meter 1.877 0.0222 0.2305 2.1297
12+
D1.8 Dynamic Peak Pricing 1.877 0.0187 0.1934 2.0891
1113
D1.11 Standard TOU 1.877 0.0221
12-
D1.13 Overnight Savers 1.877 0.0221
14+
D1.13 Overnight Savers 1.877 0.0221 0.2292 2.1283
15+
Commercial
16+
D3 General Service 1.877 0.0180 0.1867 2.0817
17+
C9 SURCHARGES AND CREDITS APPLICABLE TO DELIVERY SERVICE
18+
D1.11 Standard TOU 0.0890 0.4488 0.1617 0.2762
1319
"""
1420

1521

@@ -23,7 +29,13 @@ def __init__(self, _bytes):
2329
self.pages = [_FakePage()]
2430

2531

26-
def test_parse_pscr_cents_from_mpsc_rate_book_pdf(monkeypatch):
32+
def test_parse_pscr_rates_from_mpsc_rate_book_pdf(monkeypatch):
2733
monkeypatch.setattr("custom_components.dte_rates.pscr_parser.PdfReader", _FakeReader)
2834

29-
assert parse_pscr_cents_from_pdf(b"fake") == Decimal("1.877")
35+
assert parse_pscr_rates_from_pdf(b"fake") == {
36+
"D1": Decimal("1.877"),
37+
"D1.8": Decimal("1.877"),
38+
"D1.11": Decimal("1.877"),
39+
"D1.13": Decimal("1.877"),
40+
"D3": Decimal("1.877"),
41+
}

0 commit comments

Comments
 (0)