Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions custom_components/dte_rates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.components import persistent_notification

from .const import DOMAIN
from .const import CONF_NET_METERING, CONF_SELECTED_RATE, DOMAIN
from .coordinator import DteRateCoordinator
from .rate_calculator import current_export_rate_cents, current_import_rate_cents, period_display_name

Expand Down Expand Up @@ -110,20 +110,22 @@ async def _handle_show_schedule_service(call) -> None:
if coordinator is None:
return

entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None)
rate_code = call.data.get("rate_code")
if rate_code:
rate = coordinator.data.rates.get(rate_code)
else:
entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None)
rate = coordinator.data.rates.get(entry.data.get("selected_rate")) if entry else None
rate = coordinator.data.rates.get(entry.data.get(CONF_SELECTED_RATE)) if entry else None

if rate is None:
return

lines_by_season: dict[str, list[str]] = defaultdict(list)
net_metering = entry.data.get(CONF_NET_METERING, False) if entry else False
pscr_cents = getattr(coordinator.data, "pscr_rates", {}).get(rate.code)
for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)):
import_usd = float(current_import_rate_cents(period) / 100)
export_usd = float(current_export_rate_cents(period, False) / 100)
export_usd = float(current_export_rate_cents(period, net_metering, pscr_cents) / 100)
lines_by_season[period.season_name].append(
f"{period_display_name(period)}: Import ${import_usd:.4f}/kWh | Export ${export_usd:.4f}/kWh"
)
Expand Down
16 changes: 15 additions & 1 deletion custom_components/dte_rates/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,18 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
vol.Optional(CONF_NET_METERING, default=False): bool,
}
)
return self.async_show_form(step_id="user", data_schema=schema)
return self.async_show_form(
step_id="user",
data_schema=schema,
description_placeholders={
"rider18_status": _rider18_status(coordinator.data),
},
)


def _rider18_status(rate_card) -> str:
count = len(getattr(rate_card, "pscr_rates", {}))
if count:
suffix = "tariff" if count == 1 else "tariffs"
return f"Rider 18 export credits use parsed generation rates plus MPSC PSCR factors loaded for {count} {suffix}."
return "Rider 18 export credits use parsed generation rates plus MPSC PSCR when the selected tariff has a PSCR factor."
11 changes: 11 additions & 0 deletions custom_components/dte_rates/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
"residential/Service-Request/pricing/residential-pricing-options/"
"ResidentialElectricRateCard.pdf"
)
PSCR_RATE_BOOK_URL = (
"https://www.michigan.gov/-/media/Project/Websites/mpsc/consumer/"
"rate-books/electric/dte/dtee1cur.pdf"
)

UPDATE_INTERVAL = timedelta(days=7)

Expand All @@ -19,6 +23,13 @@
ATTR_COMPONENTS = "components"
ATTR_MONTHLY_COMPONENTS = "monthly_components"
ATTR_SOURCE_URL = "source_url"
ATTR_PSCR_CENTS = "pscr_cents"
ATTR_PSCR_RATE_CODE = "pscr_rate_code"
ATTR_PSCR_RATES = "pscr_rates"
ATTR_PSCR_SOURCE_URL = "pscr_source_url"
ATTR_RIDER18_EXPORT_AVAILABLE = "rider18_export_available"
ATTR_EXPORT_RATE_SOURCE = "export_rate_source"
ATTR_EXPORT_RATE_WARNING = "export_rate_warning"
ATTR_CARD_EFFECTIVE_DATE = "card_effective_date"
ATTR_SELECTED_RATE_AVAILABLE = "selected_rate_available"
ATTR_WARNING = "warning"
Expand Down
40 changes: 38 additions & 2 deletions custom_components/dte_rates/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import RATE_CARD_URL, UPDATE_INTERVAL
from .const import PSCR_RATE_BOOK_URL, RATE_CARD_URL, UPDATE_INTERVAL
from .models import ParsedRateCard
from .pdf_parser import parse_rate_card_pdf
from .pscr_parser import parse_pscr_rates_from_pdf

_LOGGER = logging.getLogger(__name__)

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

pscr_bytes: bytes | None = None
try:
return await self.hass.async_add_executor_job(
async with session.get(
PSCR_RATE_BOOK_URL,
headers={
"Accept": "application/pdf,*/*",
"User-Agent": "DTE-Rates-for-Home-Assistant/1.0",
},
timeout=60,
) as resp:
resp.raise_for_status()
pscr_bytes = await resp.read()
except Exception as err:
_LOGGER.warning(
"Failed downloading MPSC DTE rate book; export rates will omit PSCR: %s",
err,
)

try:
parsed = await self.hass.async_add_executor_job(
parse_rate_card_pdf,
pdf_bytes,
RATE_CARD_URL,
)
except Exception as err:
raise UpdateFailed(f"Failed parsing DTE rate card: {err}") from err

if pscr_bytes is None:
return parsed

try:
parsed.pscr_rates = await self.hass.async_add_executor_job(
parse_pscr_rates_from_pdf,
pscr_bytes,
)
parsed.pscr_source_url = PSCR_RATE_BOOK_URL
except Exception as err:
_LOGGER.warning(
"Failed parsing MPSC DTE rate book PSCR; export rates will omit PSCR: %s",
err,
)

return parsed
2 changes: 2 additions & 0 deletions custom_components/dte_rates/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ class ParsedRateCard:
effective_date: str | None
rates: dict[str, RatePlan]
raw_text_hash: str
pscr_rates: dict[str, Decimal] = field(default_factory=dict)
pscr_source_url: str | None = None
52 changes: 52 additions & 0 deletions custom_components/dte_rates/pscr_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import annotations

from decimal import Decimal
import io
import re

from pypdf import PdfReader


_RATE_ROW_RE = re.compile(
r"^\s*(?P<code>[A-Z]\d+(?:\.\d+)?)\s+.+?\s+(?P<pscr>-?\d+\.\d+)\s+\d+\.\d+",
re.IGNORECASE,
)


def parse_pscr_rates_from_pdf(pdf_bytes: bytes) -> dict[str, Decimal]:
"""Extract current PSCR values by tariff code from the MPSC DTE electric rate book."""
reader = PdfReader(io.BytesIO(pdf_bytes))
text = "\n".join(page.extract_text() or "" for page in reader.pages)
lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()]

section = _power_supply_surcharge_lines(lines)
rates: dict[str, Decimal] = {}
for line in section:
match = _RATE_ROW_RE.match(line)
if match:
rates[match.group("code").upper()] = Decimal(match.group("pscr"))

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


def _power_supply_surcharge_lines(lines: list[str]) -> list[str]:
start = None
for idx, line in enumerate(lines):
lower = line.lower()
if "c8.5 surcharges and credits applicable to power supply service" in lower:
start = idx
break

if start is None:
return lines

end = len(lines)
for idx in range(start + 1, len(lines)):
lower = lines[idx].lower()
if "c9 surcharges and credits applicable to delivery service" in lower:
end = idx
break

return lines[start:end]
32 changes: 26 additions & 6 deletions custom_components/dte_rates/rate_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,35 @@ def current_import_rate_cents(period: SeasonalPeriodRate) -> Decimal:
return period.components.per_kwh_total


def current_export_rate_cents(period: SeasonalPeriodRate, net_metering: bool) -> Decimal:
def _component_total(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> Decimal:
total = Decimal("0")
for key, value in period.components.per_kwh.items():
if any(marker in key for marker in markers):
total += value
return total


def _has_component(period: SeasonalPeriodRate, markers: tuple[str, ...]) -> bool:
return any(any(marker in key for marker in markers) for key in period.components.per_kwh)


def rider18_export_rate_cents(period: SeasonalPeriodRate, pscr_cents: Decimal | None = None) -> Decimal:
return _component_total(period, GENERATION_COMPONENT_MARKERS) + (pscr_cents or Decimal("0"))


def rider18_export_formula_available(period: SeasonalPeriodRate, pscr_cents: Decimal | None = None) -> bool:
return _has_component(period, GENERATION_COMPONENT_MARKERS) and pscr_cents is not None


def current_export_rate_cents(
period: SeasonalPeriodRate,
net_metering: bool,
pscr_cents: Decimal | None = None,
) -> Decimal:
if net_metering:
return period.components.per_kwh_total

generation_only = Decimal("0")
for key, value in period.components.per_kwh.items():
if any(marker in key for marker in GENERATION_COMPONENT_MARKERS):
generation_only += value
return generation_only
return rider18_export_rate_cents(period, pscr_cents)


def period_display_name(period: SeasonalPeriodRate) -> str:
Expand Down
62 changes: 58 additions & 4 deletions custom_components/dte_rates/sensor.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections import defaultdict
from decimal import Decimal

from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.components import persistent_notification
Expand All @@ -19,11 +20,18 @@
ATTR_MONTHLY_COMPONENTS,
ATTR_PERIOD,
ATTR_CURRENT_RATE_NAME,
ATTR_EXPORT_RATE_SOURCE,
ATTR_EXPORT_RATE_WARNING,
ATTR_NEXT_RATE_CHANGE,
ATTR_NEXT_RATE_NAME,
ATTR_NEXT_RATE_VALUE,
ATTR_PSCR_CENTS,
ATTR_PSCR_RATE_CODE,
ATTR_PSCR_RATES,
ATTR_PSCR_SOURCE_URL,
ATTR_RATE_CODE,
ATTR_RATE_NAME,
ATTR_RIDER18_EXPORT_AVAILABLE,
ATTR_SCHEDULE_BY_SEASON,
ATTR_SCHEDULE_TEXT,
ATTR_SEASON,
Expand All @@ -41,6 +49,7 @@
get_active_period,
get_next_rate_change,
period_display_name,
rider18_export_formula_available,
)


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

def _pscr_cents_for_rate(self, rate: RatePlan | None = None) -> Decimal | None:
target_rate = rate or self._selected_rate()
if target_rate is None:
return None
return getattr(self.coordinator.data, "pscr_rates", {}).get(target_rate.code)

def _export_rate_cents(self, period: SeasonalPeriodRate):
return current_export_rate_cents(
period,
self._entry.data.get(CONF_NET_METERING, False),
self._pscr_cents_for_rate(),
)

def _export_rate_source(self, period: SeasonalPeriodRate) -> str:
if self._entry.data.get(CONF_NET_METERING, False):
return "net_metering"
if rider18_export_formula_available(period, self._pscr_cents_for_rate()):
return "rider18_formula"
return "rider18_formula_incomplete"

def _export_rate_warning(self, period: SeasonalPeriodRate) -> str | None:
if self._entry.data.get(CONF_NET_METERING, False):
return None
if rider18_export_formula_available(period, self._pscr_cents_for_rate()):
return None
return (
"Rider 18 formula is missing a generation component or PSCR value "
"for the active period; using the available formula components only."
)

def _warning(self) -> str | None:
selected = self._entry.data[CONF_SELECTED_RATE]
if selected not in self.coordinator.data.rates:
Expand All @@ -118,6 +157,16 @@ def _base_attributes(self) -> dict:
ATTR_SOURCE_URL: self.coordinator.data.source_url,
ATTR_CARD_EFFECTIVE_DATE: self.coordinator.data.effective_date,
}
pscr_cents = self._pscr_cents_for_rate()
if pscr_cents is not None:
attrs[ATTR_PSCR_CENTS] = float(pscr_cents)
rate = self._selected_rate()
if rate is not None:
attrs[ATTR_PSCR_RATE_CODE] = rate.code
if self.coordinator.data.pscr_rates:
attrs[ATTR_PSCR_RATES] = {code: float(value) for code, value in self.coordinator.data.pscr_rates.items()}
if self.coordinator.data.pscr_source_url:
attrs[ATTR_PSCR_SOURCE_URL] = self.coordinator.data.pscr_source_url

rate = self._selected_rate()
period = self._active_period()
Expand Down Expand Up @@ -260,13 +309,19 @@ def native_value(self) -> float | None:
def extra_state_attributes(self) -> dict:
attrs = self._base_attributes()
attrs[CONF_NET_METERING] = self._entry.data.get(CONF_NET_METERING, False)
period = self._active_period()
if period is not None:
attrs[ATTR_EXPORT_RATE_SOURCE] = self._export_rate_source(period)
attrs[ATTR_RIDER18_EXPORT_AVAILABLE] = rider18_export_formula_available(period, self._pscr_cents_for_rate())
warning = self._export_rate_warning(period)
if warning is not None:
attrs[ATTR_EXPORT_RATE_WARNING] = warning
return attrs

def _period_value_usd(self, period: SeasonalPeriodRate | None) -> float | None:
if period is None:
return None
cents = current_export_rate_cents(period, self._entry.data.get(CONF_NET_METERING, False))
return float(cents / 100)
return float(self._export_rate_cents(period) / 100)


class DteCurrentRateNameSensor(_DteBaseRateSensor):
Expand Down Expand Up @@ -328,7 +383,6 @@ def extra_state_attributes(self) -> dict:

def _schedule_rows(self, rate: RatePlan) -> list[dict]:
rows: list[dict] = []
net_metering = self._entry.data.get(CONF_NET_METERING, False)
for period in sorted(rate.periods, key=lambda p: (p.season_name, p.period_name)):
rows.append(
{
Expand All @@ -337,7 +391,7 @@ def _schedule_rows(self, rate: RatePlan) -> list[dict]:
"name": period_display_name(period),
"time_window": self._window_summary(period),
"import_usd_per_kwh": round(float(current_import_rate_cents(period) / 100), 6),
"export_usd_per_kwh": round(float(current_export_rate_cents(period, net_metering) / 100), 6),
"export_usd_per_kwh": round(float(self._export_rate_cents(period) / 100), 6),
}
)
return rows
Expand Down
2 changes: 1 addition & 1 deletion custom_components/dte_rates/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"step": {
"user": {
"title": "DTE Residential Rates",
"description": "Choose your DTE residential electric pricing plan.",
"description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}",
"data": {
"selected_rate": "Rate plan",
"net_metering": "Net metering enabled"
Expand Down
2 changes: 1 addition & 1 deletion custom_components/dte_rates/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"step": {
"user": {
"title": "DTE Residential Rates",
"description": "Choose your DTE residential electric pricing plan.",
"description": "Choose your DTE residential electric pricing plan.\n\n{rider18_status}",
"data": {
"selected_rate": "Rate plan",
"net_metering": "Net metering enabled"
Expand Down
9 changes: 9 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Project Notes

This directory stores research and implementation notes that support changes to the DTE Rates integration.

## Structure

- `research/` - Source observations, mapping decisions, and implementation notes gathered while working on external rate-card or calculator data.

Use these notes as context for why parsers map external DTE documents into Home Assistant entities. The code remains the source of truth for behavior.
Loading
Loading