power-compare/app/costing/aer_mapper.py
dlawler489 84c4922bbc Initial commit: solar usage vs AER plan comparison app
FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2),
caches AER Consumer Data Right electricity plan data, and compares actual
metered usage against every available plan to estimate real annual cost
(TOU, tiered feed-in, and flat tariffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:07:01 +10:00

215 lines
9.6 KiB
Python

"""Maps a raw AER "Get Generic Plan Detail" JSON payload into the common
`TariffPlan` shape used by the costing engine.
Verified against real plan data while building this (AGL/EnergyAustralia
QLD plans): `electricityContract.tariffPeriod[]` holds one block per
season, each with `rateBlockUType` of "singleRate" or "timeOfUseRates" (an
"ALL" season shows as a start/end date spanning the full year, e.g.
"07-01".."06-30", which is treated as no seasonal restriction). Tiered/block
or demand-charge structures aren't represented by this shape and are marked
not-modelable rather than silently mis-costed.
"""
from __future__ import annotations
from datetime import time as time_
from app.costing.tariff_models import (
ConditionalDiscount,
FeedInComponent,
TariffPlan,
TieredFeedIn,
TimeWindow,
UsageComponent,
)
def _parse_time(hhmm: str) -> time_:
hh, mm = (int(x) for x in hhmm.split(":"))
return time_(hh, mm)
def _end_time_exclusive(hhmm: str) -> time_:
"""AER end times are inclusive of their minute (e.g. "20:59" means up
to but not including 21:00) -- shift by one minute to get the exclusive
boundary `TimeWindow` expects."""
hh, mm = (int(x) for x in hhmm.split(":"))
total = (hh * 60 + mm + 1) % (24 * 60)
return time_(total // 60, total % 60)
def _season_months(start_mmdd: str, end_mmdd: str) -> list[int] | None:
"""A tariffPeriod's startDate/endDate ("MM-DD") approximate to whole
months here (the engine's TimeContext is month-granular, not
day-of-month-granular). A span covering all 12 months is treated as
"all year" (None) rather than an explicit list."""
start_month = int(start_mmdd.split("-")[0])
end_month = int(end_mmdd.split("-")[0])
months = []
m = start_month
for _ in range(12):
months.append(m)
if m == end_month:
break
m = m % 12 + 1
return None if len(months) >= 12 else months
def _time_windows(entries: list[dict]) -> list[TimeWindow]:
return [
TimeWindow(days=tou["days"], start_time=_parse_time(tou["startTime"]), end_time=_end_time_exclusive(tou["endTime"]))
for tou in entries
]
def _rate_cents(rates: list[dict]) -> tuple[float, bool]:
"""Returns (cents_per_kwh, is_tiered). Only the first tier is used for
tiered/inclining-block rate structures -- callers should mark the plan
not-modelable when is_tiered is True, since later tiers are ignored."""
return float(rates[0]["unitPrice"]) * 100, len(rates) > 1
def map_aer_plan(list_entry: dict, detail: dict) -> TariffPlan:
plan_id = list_entry["planId"]
retailer = list_entry.get("brandName") or list_entry.get("brand", "")
plan_name = detail.get("displayName", plan_id)
distributors = list_entry.get("geography", {}).get("distributors", [])
ec = detail.get("electricityContract", {})
notes: list[str] = []
modelable = True
usage_components: list[UsageComponent] = []
supply_charges_c: list[float] = []
for period in ec.get("tariffPeriod", []):
season_months = _season_months(period.get("startDate", "01-01"), period.get("endDate", "12-31"))
rate_type = period.get("rateBlockUType")
supply_charges_c.append(float(period.get("dailySupplyCharge", 0) or 0) * 100)
if rate_type == "singleRate":
block = period["singleRate"]
rate_c, tiered = _rate_cents(block["rates"])
usage_components.append(
UsageComponent(label=block.get("displayName", "Usage"), rate_c_per_kwh=rate_c, season_months=season_months)
)
if tiered:
modelable = False
notes.append(f"'{block.get('displayName', 'Usage')}' has tiered/step pricing; only the first tier is modeled.")
elif rate_type == "timeOfUseRates":
for tou_block in period["timeOfUseRates"]:
rate_c, tiered = _rate_cents(tou_block["rates"])
usage_components.append(
UsageComponent(
label=tou_block.get("displayName", tou_block.get("type", "TOU")),
rate_c_per_kwh=rate_c,
time_windows=_time_windows(tou_block.get("timeOfUse", [])),
season_months=season_months,
)
)
if tiered:
modelable = False
notes.append(f"'{tou_block.get('displayName', 'TOU')}' has tiered/step pricing; only the first tier is modeled.")
else:
modelable = False
notes.append(f"Tariff structure '{rate_type}' is not modeled (e.g. tiered/block or demand pricing).")
if ec.get("controlledLoad"):
notes.append(
"This plan also has a separately-metered controlled load tariff, not included "
"(usage data here is whole-house, not sub-metered)."
)
feed_in_tariffs: list[FeedInComponent] = []
tiered_feed_in: TieredFeedIn | None = None
retailer_fits = [fit for fit in ec.get("solarFeedInTariff", []) if fit.get("payerType") == "RETAILER"]
# Government/legacy premium schemes aren't available to new or existing
# standard sign-ups; only the retailer's own standard feed-in tariff(s)
# apply to a household comparing plans now.
single_tariff_fits = [fit for fit in retailer_fits if fit.get("tariffUType") == "singleTariff"]
def _has_volume(fit: dict) -> bool:
return "volume" in fit["singleTariff"]["rates"][0]
if len(single_tariff_fits) == 2 and _has_volume(single_tariff_fits[0]) and not _has_volume(single_tariff_fits[1]):
# A daily-volume-based feed-in tariff (e.g. "first 10kWh/day at 8c,
# thereafter at 4c") is represented in this data as two separate
# singleTariff entries rather than as tiers within one -- the first
# entry's "volume" is the daily kWh threshold, and the second
# (uncapped) entry is the remainder rate. Without this, both were
# treated as independent flat components and the first one in the
# list silently won every match, crediting 100% of exported energy
# at the (higher) capped-tier rate.
below_c, _ = _rate_cents(single_tariff_fits[0]["singleTariff"]["rates"])
above_c, _ = _rate_cents(single_tariff_fits[1]["singleTariff"]["rates"])
tiered_feed_in = TieredFeedIn(
daily_threshold_kwh=float(single_tariff_fits[0]["singleTariff"]["rates"][0]["volume"]),
rate_below_c_per_kwh=below_c,
rate_above_c_per_kwh=above_c,
)
else:
for fit in single_tariff_fits:
rate_c, tiered = _rate_cents(fit["singleTariff"]["rates"])
feed_in_tariffs.append(FeedInComponent(label=fit.get("displayName", "Feed-in tariff"), rate_c_per_kwh=rate_c))
if tiered:
modelable = False
notes.append(f"'{fit.get('displayName', 'Feed-in tariff')}' has tiered/step pricing; only the first tier is modeled.")
for fit in retailer_fits:
if fit.get("tariffUType") == "singleTariff":
continue # handled above
elif fit.get("tariffUType") == "timeVaryingTariffs":
for block in fit.get("timeVaryingTariffs", []):
rate_c, tiered = _rate_cents(block["rates"])
feed_in_tariffs.append(
FeedInComponent(
label=block.get("displayName", "Feed-in tariff"),
rate_c_per_kwh=rate_c,
# Unlike usage-side timeOfUseRates blocks (which key their
# windows as "timeOfUse"), the CDR solarFeedInTariff
# timeVaryingTariffs shape keys them as "timeVariations" --
# using the wrong key here silently made every time-varying
# feed-in tariff match all day (empty time_windows == "always").
time_windows=_time_windows(block.get("timeVariations", [])),
)
)
else:
notes.append(f"Feed-in tariff structure '{fit.get('tariffUType')}' not modeled; solar export credit may be understated.")
discounts: list[ConditionalDiscount] = []
for d in ec.get("discounts", []):
method = d.get("methodUType")
sub = d.get(method, {}) if method else {}
percent = float(sub["rate"]) * 100 if "rate" in sub else None
amount = float(sub["amount"]) if "amount" in sub else None
discounts.append(
ConditionalDiscount(
description=d.get("displayName", d.get("description", "Discount")),
percent=percent,
amount_dollars=amount,
applies_to="usage",
auto_apply=(d.get("type") == "GUARANTEED"),
)
)
for incentive in ec.get("incentives", []):
label = incentive.get("displayName") or incentive.get("description", "")
notes.append(f"One-off sign-up incentive not included in the cost estimate: {label}")
supply_charge_c_per_day = supply_charges_c[0] if supply_charges_c else 0.0
if len(set(supply_charges_c)) > 1:
notes.append("Supply charge varies by season in the source data; using the first season's rate.")
return TariffPlan(
plan_id=plan_id,
retailer=retailer,
plan_name=plan_name,
supply_charge_c_per_day=supply_charge_c_per_day,
usage_components=usage_components,
feed_in_tariffs=feed_in_tariffs,
tiered_feed_in=tiered_feed_in,
discounts=discounts,
distributors=distributors,
source="aer",
modelable=modelable and bool(usage_components),
modeling_notes=notes,
)