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

135 lines
4.7 KiB
Python

from __future__ import annotations
from datetime import time as time_
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel
from app.costing.tariff_models import (
ALL_DAYS,
ConditionalDiscount,
FeedInComponent,
TariffPlan,
TieredFeedIn,
TimeWindow,
UsageComponent,
)
from app.db.repositories import get_current_plan, get_household_config, set_current_plan
from app.db.session import get_session
router = APIRouter(prefix="/api/settings", tags=["settings"])
class TouPeriodIn(BaseModel):
label: str
rate_c_per_kwh: float
start_time: time_
end_time: time_
days: list[str] | None = None # None = every day
class CurrentPlanIn(BaseModel):
retailer_name: str
plan_name: str
supply_charge_dollars_per_day: float
tariff_type: Literal["flat", "time_of_use"]
flat_rate_c_per_kwh: float | None = None
tou_periods: list[TouPeriodIn] | None = None
feed_in_type: Literal["flat", "tiered"] = "flat"
feed_in_tariff_c_per_kwh: float = 0.0
tiered_daily_threshold_kwh: float | None = None
tiered_rate_below_c_per_kwh: float | None = None
tiered_rate_above_c_per_kwh: float | None = None
discount_percent: float | None = None # a discount already baked into the plan you signed up for
class HouseholdIn(BaseModel):
distributor: str
postcode: str
isolarcloud_ps_id: str | None = None
def _plan_from_form(form: CurrentPlanIn) -> TariffPlan:
if form.tariff_type == "flat":
if form.flat_rate_c_per_kwh is None:
raise ValueError("flat_rate_c_per_kwh is required for a flat tariff")
usage_components = [UsageComponent(label="Usage", rate_c_per_kwh=form.flat_rate_c_per_kwh)]
else:
if not form.tou_periods:
raise ValueError("tou_periods is required for a time_of_use tariff")
usage_components = [
UsageComponent(
label=p.label,
rate_c_per_kwh=p.rate_c_per_kwh,
time_windows=[TimeWindow(days=p.days or list(ALL_DAYS), start_time=p.start_time, end_time=p.end_time)],
)
for p in form.tou_periods
]
discounts = []
if form.discount_percent:
discounts.append(
ConditionalDiscount(description="Plan discount", percent=form.discount_percent, applies_to="usage", auto_apply=True)
)
feed_in_tariffs: list[FeedInComponent] = []
tiered_feed_in: TieredFeedIn | None = None
if form.feed_in_type == "tiered":
if form.tiered_daily_threshold_kwh is None or form.tiered_rate_below_c_per_kwh is None or form.tiered_rate_above_c_per_kwh is None:
raise ValueError("tiered_daily_threshold_kwh, tiered_rate_below_c_per_kwh and tiered_rate_above_c_per_kwh are required for a tiered feed-in tariff")
tiered_feed_in = TieredFeedIn(
daily_threshold_kwh=form.tiered_daily_threshold_kwh,
rate_below_c_per_kwh=form.tiered_rate_below_c_per_kwh,
rate_above_c_per_kwh=form.tiered_rate_above_c_per_kwh,
)
else:
feed_in_tariffs = [FeedInComponent(label="Feed-in tariff", rate_c_per_kwh=form.feed_in_tariff_c_per_kwh)]
return TariffPlan(
plan_id="current-plan",
retailer=form.retailer_name,
plan_name=form.plan_name,
supply_charge_c_per_day=form.supply_charge_dollars_per_day * 100,
usage_components=usage_components,
feed_in_tariffs=feed_in_tariffs,
tiered_feed_in=tiered_feed_in,
discounts=discounts,
source="manual",
)
@router.get("/current-plan")
def get_current_plan_route() -> dict | None:
with get_session() as session:
plan = get_current_plan(session)
if plan is None:
return None
return plan.model_dump(mode="json")
@router.post("/current-plan")
def save_current_plan(form: CurrentPlanIn) -> dict:
plan = _plan_from_form(form)
with get_session() as session:
set_current_plan(session, form.retailer_name, form.plan_name, plan)
return plan.model_dump(mode="json")
@router.get("/household")
def get_household() -> dict:
with get_session() as session:
cfg = get_household_config(session)
return {"distributor": cfg.distributor, "postcode": cfg.postcode, "isolarcloud_ps_id": cfg.isolarcloud_ps_id}
@router.post("/household")
def save_household(form: HouseholdIn) -> dict:
with get_session() as session:
cfg = get_household_config(session)
cfg.distributor = form.distributor
cfg.postcode = form.postcode
if form.isolarcloud_ps_id is not None:
cfg.isolarcloud_ps_id = form.isolarcloud_ps_id
session.flush()
return {"distributor": cfg.distributor, "postcode": cfg.postcode, "isolarcloud_ps_id": cfg.isolarcloud_ps_id}