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

86 lines
3.1 KiB
Python

from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query
from app.db.repositories import get_earliest_reading_ts, get_usage_series
from app.db.session import get_session
router = APIRouter(prefix="/api/usage", tags=["usage"])
BNE = ZoneInfo("Australia/Brisbane")
def _resolve_start(session, days: str, end: datetime) -> datetime:
if days == "all":
return get_earliest_reading_ts(session) or end
try:
n = int(days)
except ValueError:
raise HTTPException(400, "days must be an integer or 'all'")
if n < 1:
raise HTTPException(400, "days must be >= 1")
return end - timedelta(days=n)
def _bucket_key(local_date, granularity: str) -> str:
if granularity == "month":
return local_date.replace(day=1).isoformat()
if granularity == "week":
return (local_date - timedelta(days=local_date.weekday())).isoformat()
return local_date.isoformat()
@router.get("/daily")
def daily_usage(days: str = Query("30")) -> list[dict]:
end = datetime.now(timezone.utc)
with get_session() as session:
start = _resolve_start(session, days, end)
rows = get_usage_series(session, start, end)
# Plotting one bar per day is unreadable (and slow) once the range
# stretches into years, so roll up to weekly/monthly buckets for long
# ranges -- this is a display concern only, the underlying stored data
# stays at full 5-minute resolution.
span_days = (end - start).days
granularity = "month" if span_days > 550 else "week" if span_days > 120 else "day"
by_bucket: dict[str, dict[str, float]] = defaultdict(lambda: {"generation_kwh": 0.0, "load_kwh": 0.0, "import_kwh": 0.0, "export_kwh": 0.0})
for row in rows:
key = _bucket_key(row.ts.astimezone(BNE).date(), granularity)
bucket = by_bucket[key]
bucket["generation_kwh"] += row.generation_kwh
bucket["load_kwh"] += row.load_kwh
bucket["import_kwh"] += row.import_kwh
bucket["export_kwh"] += row.export_kwh
return [{"date": day, **{k: round(v, 3) for k, v in totals.items()}} for day, totals in sorted(by_bucket.items())]
@router.get("/summary")
def usage_summary(days: str = Query("30")) -> dict:
end = datetime.now(timezone.utc)
with get_session() as session:
start = _resolve_start(session, days, end)
rows = get_usage_series(session, start, end)
if not rows:
return {"days_with_data": 0}
dates_seen: set[str] = set()
totals = {"generation_kwh": 0.0, "load_kwh": 0.0, "import_kwh": 0.0, "export_kwh": 0.0}
for row in rows:
dates_seen.add(row.ts.astimezone(BNE).date().isoformat())
totals["generation_kwh"] += row.generation_kwh
totals["load_kwh"] += row.load_kwh
totals["import_kwh"] += row.import_kwh
totals["export_kwh"] += row.export_kwh
return {
"days_with_data": len(dates_seen),
"first_day": min(dates_seen),
"last_day": max(dates_seen),
**{k: round(v, 2) for k, v in totals.items()},
}