power-compare/app/costing/engine.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

244 lines
9.3 KiB
Python

"""Evaluates a TariffPlan against a stream of interval usage readings.
No battery/storage modeling is needed: the household has solar-only
generation, so metered import/export values are ground truth and are used
directly rather than re-derived from generation and load.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime
from datetime import time as time_
from zoneinfo import ZoneInfo
from app.costing.tariff_models import DAYS, FeedInComponent, TariffPlan, TimeContext, UsageComponent
# Queensland does not observe daylight saving, but readings are still stored
# in UTC and converted here rather than assuming a fixed UTC+10 offset, so
# the engine isn't silently wrong if it's ever reused for a DST-observing state.
BRISBANE_TZ = ZoneInfo("Australia/Brisbane")
@dataclass
class IntervalReading:
ts: datetime # timezone-aware (UTC)
import_kwh: float
export_kwh: float
@dataclass
class ComponentBreakdown:
label: str
kwh: float
cost_dollars: float
@dataclass
class CostBreakdown:
plan_id: str
retailer: str
plan_name: str
usage_breakdown: list[ComponentBreakdown]
feed_in_breakdown: list[ComponentBreakdown]
supply_charge_dollars: float
unconditional_discount_dollars: float
potential_conditional_savings_dollars: float
total_dollars: float
days_with_data: int
modelable: bool
modeling_notes: list[str] = field(default_factory=list)
@property
def annualized_total_dollars(self) -> float | None:
if self.days_with_data == 0:
return None
return self.total_dollars * 365 / self.days_with_data
@property
def is_estimate(self) -> bool:
return self.days_with_data < 365
def _first_match(
components: list[UsageComponent] | list[FeedInComponent], ctx: TimeContext
) -> UsageComponent | FeedInComponent | None:
for c in components:
if c.matches(ctx):
return c
return None
@dataclass
class TimeBucket:
"""A (month, day-of-week, time-of-day) point with import/export summed
across every raw reading that shares it. Comparing many plans against
the same usage history is much cheaper when every plan re-matches this
small bucketed set instead of every raw reading -- see `bucket_readings`.
"""
ctx: TimeContext
import_kwh: float
export_kwh: float
def bucket_readings(readings: Iterable[IntervalReading]) -> tuple[list[TimeBucket], int, dict[str, float]]:
"""Groups readings by (month, weekday, hour, minute) and sums their
kWh, since a time-of-use tariff's cost only ever depends on that
triple, never on the specific calendar date. Also returns the count of
distinct calendar days with data (for the supply charge) and each
calendar day's total export (for tiered daily feed-in tariffs, which
depend on same-day cumulative export rather than time of day) --
computed once up front so neither needs to be re-derived per plan.
Bucketing turns "N plans x M raw readings" into "N plans x <= a few
thousand buckets", which matters once there are hundreds of cached AER
plans to evaluate against months of 5-minute interval history.
"""
sums: dict[tuple[int, str, int, int], list[float]] = {}
daily_export_kwh: dict[str, float] = {}
for r in readings:
local_dt = r.ts.astimezone(BRISBANE_TZ)
day = local_dt.date().isoformat()
daily_export_kwh[day] = daily_export_kwh.get(day, 0.0) + r.export_kwh
key = (local_dt.month, local_dt.weekday(), local_dt.hour, local_dt.minute)
bucket = sums.get(key)
if bucket is None:
sums[key] = [r.import_kwh, r.export_kwh]
else:
bucket[0] += r.import_kwh
bucket[1] += r.export_kwh
buckets = [
TimeBucket(
ctx=TimeContext(month=month, day_code=DAYS[weekday], time_of_day=time_(hour, minute)),
import_kwh=vals[0],
export_kwh=vals[1],
)
for (month, weekday, hour, minute), vals in sums.items()
]
return buckets, len(daily_export_kwh), daily_export_kwh
def _evaluate_core(
plan: TariffPlan, buckets: list[TimeBucket], days_with_data: int, daily_export_kwh: dict[str, float]
) -> CostBreakdown:
notes = list(plan.modeling_notes)
usage_kwh: dict[str, float] = {c.label: 0.0 for c in plan.usage_components}
feed_in_kwh: dict[str, float] = {c.label: 0.0 for c in plan.feed_in_tariffs}
unmatched_usage_kwh = 0.0
unmatched_export_kwh = 0.0
for b in buckets:
usage_component = _first_match(plan.usage_components, b.ctx)
if usage_component is not None:
usage_kwh[usage_component.label] += b.import_kwh
else:
unmatched_usage_kwh += b.import_kwh
if plan.tiered_feed_in or not b.export_kwh:
continue # tiered feed-in is computed below from daily totals instead
if plan.feed_in_tariffs:
fit_component = _first_match(plan.feed_in_tariffs, b.ctx)
if fit_component is not None:
feed_in_kwh[fit_component.label] += b.export_kwh
else:
unmatched_export_kwh += b.export_kwh
else:
unmatched_export_kwh += b.export_kwh
if unmatched_usage_kwh > 1e-9:
notes.append(
f"{unmatched_usage_kwh:.2f} kWh of usage did not match any tariff window and was not "
"charged -- this plan's time windows may have gaps."
)
if unmatched_export_kwh > 1e-9:
notes.append(
f"{unmatched_export_kwh:.2f} kWh of solar export received no feed-in credit "
"(no matching feed-in window, or this plan has no feed-in tariff)."
)
# A tariff period can need more than one disjoint time window under the
# same label (e.g. "Shoulder" = 7am-4pm AND 9pm-10pm) -- usage_kwh is
# already correctly summed per label above, so build the breakdown from
# unique labels only, or a component appearing twice would double-count
# its cost here.
rate_by_label = {c.label: c.rate_c_per_kwh for c in plan.usage_components}
usage_breakdown = [
ComponentBreakdown(label=label, kwh=kwh, cost_dollars=kwh * rate_by_label[label] / 100)
for label, kwh in usage_kwh.items()
]
if plan.tiered_feed_in:
tier = plan.tiered_feed_in
below_kwh = sum(min(v, tier.daily_threshold_kwh) for v in daily_export_kwh.values())
above_kwh = sum(max(v - tier.daily_threshold_kwh, 0.0) for v in daily_export_kwh.values())
feed_in_breakdown = [
ComponentBreakdown(
label=f"First {tier.daily_threshold_kwh:g} kWh/day",
kwh=below_kwh,
cost_dollars=-below_kwh * tier.rate_below_c_per_kwh / 100,
),
ComponentBreakdown(
label="Remaining balance",
kwh=above_kwh,
cost_dollars=-above_kwh * tier.rate_above_c_per_kwh / 100,
),
]
else:
fit_rate_by_label = {c.label: c.rate_c_per_kwh for c in plan.feed_in_tariffs}
feed_in_breakdown = [
ComponentBreakdown(label=label, kwh=kwh, cost_dollars=-kwh * fit_rate_by_label[label] / 100)
for label, kwh in feed_in_kwh.items()
]
usage_cost = sum(c.cost_dollars for c in usage_breakdown)
feed_in_credit = sum(c.cost_dollars for c in feed_in_breakdown) # already negative
# Supply charge is billed per day the meter has data, not per calendar
# day in the requested range, so a gap in polling doesn't overstate cost.
supply_charge = plan.supply_charge_c_per_day * days_with_data / 100
subtotal = usage_cost + feed_in_credit + supply_charge
unconditional_discount = 0.0
potential_conditional = 0.0
for d in plan.discounts:
base = usage_cost if d.applies_to == "usage" else subtotal
amount = (base * d.percent / 100) if d.percent is not None else (d.amount_dollars or 0.0)
if d.auto_apply:
unconditional_discount += amount
else:
potential_conditional += amount
notes.append(f"Conditional discount not applied to headline total: {d.description} (~${amount:.2f})")
total = subtotal - unconditional_discount
return CostBreakdown(
plan_id=plan.plan_id,
retailer=plan.retailer,
plan_name=plan.plan_name,
usage_breakdown=usage_breakdown,
feed_in_breakdown=feed_in_breakdown,
supply_charge_dollars=supply_charge,
unconditional_discount_dollars=unconditional_discount,
potential_conditional_savings_dollars=potential_conditional,
total_dollars=total,
days_with_data=days_with_data,
modelable=plan.modelable,
modeling_notes=notes,
)
def evaluate(plan: TariffPlan, readings: Iterable[IntervalReading]) -> CostBreakdown:
buckets, days_with_data, daily_export_kwh = bucket_readings(readings)
return _evaluate_core(plan, buckets, days_with_data, daily_export_kwh)
def evaluate_bucketed(
plan: TariffPlan, buckets: list[TimeBucket], days_with_data: int, daily_export_kwh: dict[str, float]
) -> CostBreakdown:
"""Like `evaluate`, but takes an already-bucketed usage history (from
`bucket_readings`) so comparing many plans against the same usage data
only pays the bucketing cost once."""
return _evaluate_core(plan, buckets, days_with_data, daily_export_kwh)