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>
135 lines
4.7 KiB
Python
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}
|