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>
244 lines
9.3 KiB
Python
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)
|