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))