Skip to content

Commit 1716942

Browse files
committed
Match Rider 18 export spreadsheet formula
1 parent 2713b07 commit 1716942

13 files changed

Lines changed: 296 additions & 44 deletions

custom_components/dte_rates/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,10 @@ 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)
125126
for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)):
126127
import_usd = float(current_import_rate_cents(period) / 100)
127-
export_usd = float(current_export_rate_cents(period, net_metering) / 100)
128+
export_usd = float(current_export_rate_cents(period, net_metering, pscr_cents) / 100)
128129
lines_by_season[period.season_name].append(
129130
f"{period_display_name(period)}: Import ${import_usd:.4f}/kWh | Export ${export_usd:.4f}/kWh"
130131
)

custom_components/dte_rates/config_flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
4545

4646

4747
def _rider18_status(_rate_card) -> str:
48-
return "Rider 18 export credits are calculated from the parsed rate card as Generation + Distribution/Transmission."
48+
return "Rider 18 export credits use parsed generation rates plus PSCR when the PSCR value is available."

custom_components/dte_rates/const.py

Lines changed: 6 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+
RIDER18_CALCULATOR_URL = (
10+
"https://www.dteenergy.com/content/dam/dteenergy/deg/website/"
11+
"hybris/rooftop-solar/Rider18Calculator.xlsx"
12+
)
913

1014
UPDATE_INTERVAL = timedelta(days=7)
1115

@@ -19,6 +23,8 @@
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_SOURCE_URL = "pscr_source_url"
2228
ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available"
2329
ATTR_EXPORT_RATE_SOURCE = "export_rate_source"
2430
ATTR_EXPORT_RATE_WARNING = "export_rate_warning"

custom_components/dte_rates/coordinator.py

Lines changed: 31 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 RATE_CARD_URL, RIDER18_CALCULATOR_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
1314

1415
_LOGGER = logging.getLogger(__name__)
1516

@@ -33,11 +34,39 @@ 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(RIDER18_CALCULATOR_URL, timeout=60) as resp:
40+
resp.raise_for_status()
41+
pscr_bytes = await resp.read()
42+
except Exception as err:
43+
_LOGGER.warning(
44+
"Failed downloading DTE Rider 18 calculator; export rates will omit PSCR: %s",
45+
err,
46+
)
47+
48+
try:
49+
parsed = await self.hass.async_add_executor_job(
3850
parse_rate_card_pdf,
3951
pdf_bytes,
4052
RATE_CARD_URL,
4153
)
4254
except Exception as err:
4355
raise UpdateFailed(f"Failed parsing DTE rate card: {err}") from err
56+
57+
if pscr_bytes is None:
58+
return parsed
59+
60+
try:
61+
parsed.pscr_cents = await self.hass.async_add_executor_job(
62+
parse_pscr_cents_from_xlsx,
63+
pscr_bytes,
64+
)
65+
parsed.pscr_source_url = RIDER18_CALCULATOR_URL
66+
except Exception as err:
67+
_LOGGER.warning(
68+
"Failed parsing DTE Rider 18 PSCR; export rates will omit PSCR: %s",
69+
err,
70+
)
71+
72+
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_cents: Decimal | None = None
54+
pscr_source_url: str | None = None
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from __future__ import annotations
2+
3+
from decimal import Decimal, InvalidOperation
4+
from io import BytesIO
5+
import posixpath
6+
import re
7+
from typing import Any
8+
from xml.etree import ElementTree as ET
9+
from zipfile import ZipFile
10+
11+
12+
_CELL_RE = re.compile(r"([A-Z]+)(\d+)")
13+
14+
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)
21+
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
27+
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
32+
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

custom_components/dte_rates/rate_calculator.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88

99
GENERATION_COMPONENT_MARKERS = ("capacity_energy", "non_capacity_energy")
10-
DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS = ("distribution", "transmission")
1110

1211

1312
def _is_hour_in_range(start_hour: int, end_hour: int, hour: int) -> bool:
@@ -78,25 +77,23 @@ def _has_component(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> bool
7877
return any(any(marker in key for marker in markers) for key in period.components.per_kwh)
7978

8079

81-
def rider18_export_rate_cents(period: SeasonalPeriodRate) -> Decimal:
82-
return _component_total(period, GENERATION_COMPONENT_MARKERS) + _component_total(
83-
period,
84-
DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS,
85-
)
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"))
8682

8783

88-
def rider18_export_formula_available(period: SeasonalPeriodRate) -> bool:
89-
return _has_component(period, GENERATION_COMPONENT_MARKERS) and _has_component(
90-
period,
91-
DISTRIBUTION_TRANSMISSION_COMPONENT_MARKERS,
92-
)
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
9386

9487

95-
def current_export_rate_cents(period: SeasonalPeriodRate, net_metering: bool) -> Decimal:
88+
def current_export_rate_cents(
89+
period: SeasonalPeriodRate,
90+
net_metering: bool,
91+
pscr_cents: Decimal | None = None,
92+
) -> Decimal:
9693
if net_metering:
9794
return period.components.per_kwh_total
9895

99-
return rider18_export_rate_cents(period)
96+
return rider18_export_rate_cents(period, pscr_cents)
10097

10198

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

custom_components/dte_rates/sensor.py

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

33
from collections import defaultdict
4+
from decimal import Decimal
5+
46
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
57
from homeassistant.components import persistent_notification
68
from homeassistant.config_entries import ConfigEntry
@@ -23,6 +25,8 @@
2325
ATTR_NEXT_RATE_CHANGE,
2426
ATTR_NEXT_RATE_NAME,
2527
ATTR_NEXT_RATE_VALUE,
28+
ATTR_PSCR_CENTS,
29+
ATTR_PSCR_SOURCE_URL,
2630
ATTR_RATE_CODE,
2731
ATTR_RATE_NAME,
2832
ATTR_RIDER18_EXPORT_AVAILABLE,
@@ -106,23 +110,30 @@ def _active_period(self) -> SeasonalPeriodRate | None:
106110
return None
107111
return get_active_period(rate, dt_util.now())
108112

113+
def _pscr_cents(self) -> Decimal | None:
114+
return getattr(self.coordinator.data, "pscr_cents", None)
115+
109116
def _export_rate_cents(self, period: SeasonalPeriodRate):
110-
return current_export_rate_cents(period, self._entry.data.get(CONF_NET_METERING, False))
117+
return current_export_rate_cents(
118+
period,
119+
self._entry.data.get(CONF_NET_METERING, False),
120+
self._pscr_cents(),
121+
)
111122

112123
def _export_rate_source(self, period: SeasonalPeriodRate) -> str:
113124
if self._entry.data.get(CONF_NET_METERING, False):
114125
return "net_metering"
115-
if rider18_export_formula_available(period):
126+
if rider18_export_formula_available(period, self._pscr_cents()):
116127
return "rider18_formula"
117128
return "rider18_formula_incomplete"
118129

119130
def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None:
120131
if self._entry.data.get(CONF_NET_METERING, False):
121132
return None
122-
if rider18_export_formula_available(period):
133+
if rider18_export_formula_available(period, self._pscr_cents()):
123134
return None
124135
return (
125-
"Rider 18 formula is missing a generation or distribution/transmission component "
136+
"Rider 18 formula is missing a generation component or PSCR value "
126137
"for the active period; using the available formula components only."
127138
)
128139

@@ -141,6 +152,11 @@ def _base_attributes(self) -> dict:
141152
ATTR_SOURCE_URL: self.coordinator.data.source_url,
142153
ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date,
143154
}
155+
if self.coordinator.data.pscr_cents is not None:
156+
attrs[ATTR_PSCR_CENTS] = float(self.coordinator.data.pscr_cents)
157+
if self.coordinator.data.pscr_source_url:
158+
attrs[ATTR_PSCR_SOURCE_URL] = self.coordinator.data.pscr_source_url
159+
144160
rate = self._selected_rate()
145161
period = self._active_period()
146162
if rate is None or period is None:
@@ -285,7 +301,7 @@ def extra_state_attributes(self) -> dict:
285301
period = self._active_period()
286302
if period is not None:
287303
attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period)
288-
attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period)
304+
attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents())
289305
warning = self._export_rate_warning(period)
290306
if warning is not None:
291307
attrs[ATTR_EXPORT_RATE_WARNING] = warning
Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,45 @@
11
# Rider 18 Export Rates
22

3-
## Source
3+
## Sources
44

5-
- Enhancement request: calculate Rider 18 export credits from the rate card.
6-
- User-provided formula reference: `Total per-kWh Credit = Generation Rate + Distribution/Transmission Rate`.
5+
- Enhancement request: calculate Rider 18 export credits to match DTE's Rider 18 calculator.
6+
- Residential rate card: source of the active generation components for each rate period.
7+
- Rider 18 calculator workbook: source of the current PSCR scalar.
78

89
## Implementation Decision
910

10-
Do not use the Rider 18 calculator workbook for export pricing. The integration now derives non-net-metering Rider 18 export credits directly from the parsed residential rate card.
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.
1112

1213
Formula:
1314

1415
```text
15-
Rider 18 export credit = generation components + distribution/transmission components
16+
Rider 18 export credit = generation components + PSCR
1617
```
1718

18-
The parsed rate card currently identifies generation components by keys containing `capacity_energy` or `non_capacity_energy`. It identifies distribution/transmission components by keys containing `distribution` or `transmission`.
19+
The parsed rate card identifies generation components by keys containing `capacity_energy` or `non_capacity_energy`.
20+
21+
Validation against the live D1.11 workbook on May 11, 2026 showed that DTE's workbook calculates:
22+
23+
```text
24+
Rider 18 Tariff (No PSCR) = Capacity Charges + Non-Capacity Charges
25+
Outflow Credit Incl. PSCR = Rider 18 Tariff (No PSCR) + PSCR
26+
```
27+
28+
D1.11 examples:
29+
30+
| Period | Generation | PSCR | Expected export credit |
31+
| --- | ---: | ---: | ---: |
32+
| Summer On-Peak | `14.407¢/kWh` | `1.877¢/kWh` | `16.284¢/kWh` |
33+
| Summer Off-Peak | `8.709¢/kWh` | `1.877¢/kWh` | `10.586¢/kWh` |
34+
| Winter On-Peak | `10.319¢/kWh` | `1.877¢/kWh` | `12.196¢/kWh` |
35+
| Winter Off-Peak | `8.709¢/kWh` | `1.877¢/kWh` | `10.586¢/kWh` |
1936

2037
For net metering, keep the existing behavior: export uses the full active import rate from the selected rate plan.
2138

2239
## UI/Entity Status
2340

24-
The setup flow explains that Rider 18 export credits are calculated from the parsed rate card formula. Export entities expose whether the active period has enough parsed components for the full formula:
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:
2542

26-
- `export_rate_source: rider18_formula` when generation and distribution/transmission components are present.
43+
- `export_rate_source: rider18_formula` when generation components and PSCR are present.
2744
- `export_rate_source: rider18_formula_incomplete` when one side of the formula is missing.
2845
- `export_rate_source: net_metering` when net metering is enabled.

0 commit comments

Comments
 (0)