Skip to content

Commit ab13780

Browse files
committed
Source PSCR from MPSC rate book
1 parent 1716942 commit ab13780

6 files changed

Lines changed: 62 additions & 180 deletions

File tree

custom_components/dte_rates/const.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
"residential/Service-Request/pricing/residential-pricing-options/"
77
"ResidentialElectricRateCard.pdf"
88
)
9-
RIDER18_CALCULATOR_URL = (
10-
"https://www.dteenergy.com/content/dam/dteenergy/deg/website/"
11-
"hybris/rooftop-solar/Rider18Calculator.xlsx"
9+
PSCR_RATE_BOOK_URL = (
10+
"https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/"
11+
"rate-books/electric/dte/dtee1cur.pdf"
1212
)
1313

1414
UPDATE_INTERVAL = timedelta(days=7)

custom_components/dte_rates/coordinator.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +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, RIDER18_CALCULATOR_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_cents_from_xlsx
13+
from .pscr_parser import parse_pscr_cents_from_pdf
1414

1515
_LOGGER = logging.getLogger(__name__)
1616

@@ -36,12 +36,19 @@ async def _async_update_data(self) -> ParsedRateCard:
3636

3737
pscr_bytes: bytes | None = None
3838
try:
39-
async with session.get(RIDER18_CALCULATOR_URL, timeout=60) as resp:
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:
4047
resp.raise_for_status()
4148
pscr_bytes = await resp.read()
4249
except Exception as err:
4350
_LOGGER.warning(
44-
"Failed downloading DTE Rider 18 calculator; export rates will omit PSCR: %s",
51+
"Failed downloading MPSC DTE rate book; export rates will omit PSCR: %s",
4552
err,
4653
)
4754

@@ -59,13 +66,13 @@ async def _async_update_data(self) -> ParsedRateCard:
5966

6067
try:
6168
parsed.pscr_cents = await self.hass.async_add_executor_job(
62-
parse_pscr_cents_from_xlsx,
69+
parse_pscr_cents_from_pdf,
6370
pscr_bytes,
6471
)
65-
parsed.pscr_source_url = RIDER18_CALCULATOR_URL
72+
parsed.pscr_source_url = PSCR_RATE_BOOK_URL
6673
except Exception as err:
6774
_LOGGER.warning(
68-
"Failed parsing DTE Rider 18 PSCR; export rates will omit PSCR: %s",
75+
"Failed parsing MPSC DTE rate book PSCR; export rates will omit PSCR: %s",
6976
err,
7077
)
7178

Lines changed: 16 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,26 @@
11
from __future__ import annotations
22

3-
from decimal import Decimal, InvalidOperation
4-
from io import BytesIO
5-
import posixpath
3+
from decimal import Decimal
4+
import io
65
import re
7-
from typing import Any
8-
from xml.etree import ElementTree as ET
9-
from zipfile import ZipFile
106

7+
from pypdf import PdfReader
118

12-
_CELL_RE = re.compile(r"([A-Z]+)(\d+)")
139

10+
_D111_PSCR_RE = re.compile(
11+
r"\bD1\.11\s+Standard\s+TOU\s+(?P<pscr>\d+\.\d+)",
12+
re.IGNORECASE,
13+
)
1414

15-
def parse_pscr_cents_from_xlsx(xlsx_bytes: bytes) -> Decimal:
16-
"""Extract the Rider 18 PSCR value as cents/kWh."""
17-
with ZipFile(BytesIO(xlsx_bytes)) as workbook:
18-
shared_strings = _shared_strings(workbook)
19-
sheet_path = _rates_sheet_path(workbook)
20-
rows = _worksheet_rows(workbook, sheet_path, shared_strings)
2115

22-
for row_number in sorted(rows):
23-
row = rows[row_number]
24-
label = " ".join(row.values()).lower()
25-
if "pscr" not in label:
26-
continue
16+
def parse_pscr_cents_from_pdf(pdf_bytes: bytes) -> Decimal:
17+
"""Extract the current PSCR value 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+
normalized = re.sub(r"\s+", " ", text)
2721

28-
for column in ("C", "D", "B", "E"):
29-
cents = _dollars_to_cents(row.get(column))
30-
if cents is not None:
31-
return cents
22+
match = _D111_PSCR_RE.search(normalized)
23+
if match:
24+
return Decimal(match.group("pscr"))
3225

33-
raise ValueError("Rider 18 workbook does not contain a PSCR value")
34-
35-
36-
def _shared_strings(workbook: ZipFile) -> list[str]:
37-
try:
38-
root = ET.fromstring(workbook.read("xl/sharedStrings.xml"))
39-
except KeyError:
40-
return []
41-
42-
return ["".join(text.text or "" for text in item.findall(".//{*}t")) for item in root.findall("{*}si")]
43-
44-
45-
def _rates_sheet_path(workbook: ZipFile) -> str:
46-
workbook_root = ET.fromstring(workbook.read("xl/workbook.xml"))
47-
rels_root = ET.fromstring(workbook.read("xl/_rels/workbook.xml.rels"))
48-
rel_targets = {
49-
rel.attrib["Id"]: _normalize_target(rel.attrib.get("Target", ""))
50-
for rel in rels_root.findall("{*}Relationship")
51-
if "Id" in rel.attrib
52-
}
53-
54-
fallback: str | None = None
55-
for sheet in workbook_root.findall(".//{*}sheet"):
56-
name = sheet.attrib.get("name", "")
57-
rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
58-
target = rel_targets.get(rel_id or "")
59-
if target is None:
60-
continue
61-
if name.strip().lower() == "rates and credits":
62-
return target
63-
if fallback is None and "rate" in name.lower():
64-
fallback = target
65-
66-
if fallback is None:
67-
raise ValueError("Rider 18 workbook does not contain a rates sheet")
68-
return fallback
69-
70-
71-
def _normalize_target(target: str) -> str:
72-
if target.startswith("/"):
73-
return target.lstrip("/")
74-
return posixpath.normpath(posixpath.join("xl", target))
75-
76-
77-
def _worksheet_rows(
78-
workbook: ZipFile,
79-
sheet_path: str,
80-
shared_strings: list[str],
81-
) -> dict[int, dict[str, str]]:
82-
root = ET.fromstring(workbook.read(sheet_path))
83-
rows: dict[int, dict[str, str]] = {}
84-
85-
for cell in root.findall(".//{*}c"):
86-
ref = cell.attrib.get("r", "")
87-
match = _CELL_RE.match(ref)
88-
if not match:
89-
continue
90-
91-
column, row_number_raw = match.groups()
92-
value = _cell_value(cell, shared_strings)
93-
if value is not None:
94-
rows.setdefault(int(row_number_raw), {})[column] = " ".join(value.split())
95-
96-
return rows
97-
98-
99-
def _cell_value(cell: ET.Element, shared_strings: list[str]) -> str | None:
100-
cell_type = cell.attrib.get("t")
101-
if cell_type == "inlineStr":
102-
return "".join(text.text or "" for text in cell.findall(".//{*}t"))
103-
104-
value = cell.find("{*}v")
105-
if value is None or value.text is None:
106-
return None
107-
108-
if cell_type == "s":
109-
try:
110-
return shared_strings[int(value.text)]
111-
except (IndexError, ValueError):
112-
return None
113-
return value.text
114-
115-
116-
def _dollars_to_cents(value: Any) -> Decimal | None:
117-
text = str(value or "").replace("$", "").replace(",", "").strip()
118-
if not text:
119-
return None
120-
121-
try:
122-
return (abs(Decimal(text)) * Decimal("100")).quantize(Decimal("0.001"))
123-
except InvalidOperation:
124-
return None
26+
raise ValueError("MPSC DTE rate book does not contain a D1.11 PSCR value")

docs/research/rider18-export-rates.md

Lines changed: 2 additions & 2 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-
- Rider 18 calculator workbook: source of the current PSCR scalar.
7+
- MPSC DTE electric rate book: source of the current PSCR scalar.
88

99
## Implementation Decision
1010

11-
Do not use the Rider 18 calculator workbook's period-specific outflow credits as the authoritative rates. The workbook is used only to extract the current PSCR value, 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 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.
1212

1313
Formula:
1414

tests/test_pscr_parser.py

Lines changed: 26 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,29 @@
11
from __future__ import annotations
22

33
from decimal import Decimal
4-
from html import escape
5-
from io import BytesIO
6-
from zipfile import ZipFile
7-
8-
from custom_components.dte_rates.pscr_parser import parse_pscr_cents_from_xlsx
9-
10-
11-
def _xlsx_with_pscr() -> bytes:
12-
cells = {
13-
"B2": "FULL SERVICE - Rider 18 Credits",
14-
"B4": "PSCR (November 1, 2024)",
15-
"C4": "0.01877",
16-
}
17-
rows: dict[int, list[str]] = {}
18-
for ref, value in cells.items():
19-
row = int("".join(ch for ch in ref if ch.isdigit()))
20-
if value.replace(".", "", 1).isdigit():
21-
cell = f'<c r="{ref}"><v>{value}</v></c>'
22-
else:
23-
cell = f'<c r="{ref}" t="inlineStr"><is><t>{escape(value)}</t></is></c>'
24-
rows.setdefault(row, []).append(cell)
25-
26-
sheet_data = "".join(f'<row r="{row}">{"".join(cells)}</row>' for row, cells in sorted(rows.items()))
27-
workbook_xml = (
28-
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
29-
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
30-
"<sheets>"
31-
'<sheet name="Rates and Credits" sheetId="1" r:id="rId1"/>'
32-
"</sheets>"
33-
"</workbook>"
34-
)
35-
rels_xml = (
36-
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
37-
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
38-
'Target="worksheets/sheet1.xml"/>'
39-
"</Relationships>"
40-
)
41-
sheet_xml = (
42-
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
43-
f"<sheetData>{sheet_data}</sheetData>"
44-
"</worksheet>"
45-
)
46-
47-
buffer = BytesIO()
48-
with ZipFile(buffer, "w") as workbook:
49-
workbook.writestr("xl/workbook.xml", workbook_xml)
50-
workbook.writestr("xl/_rels/workbook.xml.rels", rels_xml)
51-
workbook.writestr("xl/worksheets/sheet1.xml", sheet_xml)
52-
return buffer.getvalue()
53-
54-
55-
def test_parse_pscr_cents_from_xlsx():
56-
assert parse_pscr_cents_from_xlsx(_xlsx_with_pscr()) == Decimal("1.877")
4+
5+
from custom_components.dte_rates.pscr_parser import parse_pscr_cents_from_pdf
6+
7+
8+
SAMPLE_RATE_BOOK_TEXT = """
9+
C8.5 SURCHARGES AND CREDITS APPLICABLE TO POWER SUPPLY SERVICE
10+
Rate Schedule Description PSCR Factor Other Charges
11+
D1.11 Standard TOU 1.877 0.0221
12+
D1.13 Overnight Savers 1.877 0.0221
13+
"""
14+
15+
16+
class _FakePage:
17+
def extract_text(self):
18+
return SAMPLE_RATE_BOOK_TEXT
19+
20+
21+
class _FakeReader:
22+
def __init__(self, _bytes):
23+
self.pages = [_FakePage()]
24+
25+
26+
def test_parse_pscr_cents_from_mpsc_rate_book_pdf(monkeypatch):
27+
monkeypatch.setattr("custom_components.dte_rates.pscr_parser.PdfReader", _FakeReader)
28+
29+
assert parse_pscr_cents_from_pdf(b"fake") == Decimal("1.877")

tests/test_sensor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def _coordinator_with_rate() -> SimpleNamespace:
4343
rates={"D1.11": rate},
4444
raw_text_hash="hash",
4545
pscr_cents=Decimal("1.877"),
46-
pscr_source_url="https://example.test/Rider18Calculator.xlsx",
46+
pscr_source_url="https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/rate-books/electric/dte/dtee1cur.pdf",
4747
)
4848
)
4949

0 commit comments

Comments
 (0)