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

84 lines
3.4 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from app.costing.engine import CostBreakdown, bucket_readings, evaluate_bucketed
from app.db.repositories import get_cached_plans, get_current_plan, get_interval_readings
from app.db.session import get_session
# The AER plan cache can hold thousands of near-duplicate whitelabel/partner
# variants of the same underlying offer across all configured retailers;
# dumping every one into the comparison table is neither fast nor useful, so
# only the cheapest N modelable plans are returned (see `compare_plans`).
DEFAULT_PLAN_LIMIT = 50
@dataclass
class PlanComparisonRow:
breakdown: CostBreakdown
is_current: bool
savings_vs_current_dollars: float | None # positive = cheaper than current plan
@dataclass
class PlanComparisonResult:
rows: list[PlanComparisonRow]
total_plans_considered: int
def compare_plans(start: datetime, end: datetime, limit: int = DEFAULT_PLAN_LIMIT) -> PlanComparisonResult:
with get_session() as session:
readings = get_interval_readings(session, start, end)
plans = get_cached_plans(session)
current_plan = get_current_plan(session)
# Retailers commonly publish the same underlying offer multiple times
# under different partner/channel plan IDs (e.g. a bank-affiliated
# sign-up variant) with identical terms -- collapse those before
# evaluating so the table isn't dominated by exact duplicates.
seen: set[tuple[str, str, float, tuple[float, ...]]] = set()
deduped_plans = []
for plan in plans:
fingerprint = (
plan.retailer,
plan.plan_name,
plan.supply_charge_c_per_day,
tuple(sorted(c.rate_c_per_kwh for c in plan.usage_components)),
)
if fingerprint in seen:
continue
seen.add(fingerprint)
deduped_plans.append(plan)
plans = deduped_plans
# Bucketing once and re-matching each plan against the small bucketed
# set (instead of every raw reading) is what keeps this fast with
# hundreds of cached plans and months of 5-minute interval history.
buckets, days_with_data, daily_export_kwh = bucket_readings(readings)
current_result = (
evaluate_bucketed(current_plan, buckets, days_with_data, daily_export_kwh) if current_plan else None
)
current_annual = current_result.annualized_total_dollars if current_result else None
rows: list[PlanComparisonRow] = []
if current_result:
rows.append(PlanComparisonRow(breakdown=current_result, is_current=True, savings_vs_current_dollars=0.0))
plan_rows: list[PlanComparisonRow] = []
for plan in plans:
result = evaluate_bucketed(plan, buckets, days_with_data, daily_export_kwh)
savings = None
if current_annual is not None and result.annualized_total_dollars is not None:
savings = current_annual - result.annualized_total_dollars
plan_rows.append(PlanComparisonRow(breakdown=result, is_current=False, savings_vs_current_dollars=savings))
def sort_key(row: PlanComparisonRow):
if not row.breakdown.modelable or row.breakdown.annualized_total_dollars is None:
return (1, 0.0)
return (0, row.breakdown.annualized_total_dollars)
plan_rows.sort(key=sort_key)
rows.extend(plan_rows[:limit])
return PlanComparisonResult(rows=rows, total_plans_considered=len(plan_rows))