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.costing.engine import IntervalReading, evaluate from app.db.repositories import ( get_current_plan, get_earliest_reading_ts, get_latest_reading, 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) current_plan = get_current_plan(session) 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 net_revenue_dollars = None if current_plan is not None: # Reuses the same costing engine the plan comparison runs on -- # "net revenue" is just the negative of what the current plan would # bill for this period (positive when export credit outweighs the # cost of imports). readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows] net_revenue_dollars = round(-evaluate(current_plan, readings).total_dollars, 2) return { "days_with_data": len(dates_seen), "first_day": min(dates_seen), "last_day": max(dates_seen), "net_revenue_dollars": net_revenue_dollars, **{k: round(v, 2) for k, v in totals.items()}, } @router.get("/live") def live_status() -> dict: with get_session() as session: row = get_latest_reading(session) if row is None: return {"available": False} step_hours = row.resolution_minutes / 60 if row.resolution_minutes else None def _kw(kwh: float) -> float | None: return round(kwh / step_hours, 3) if step_hours else None return { "available": True, "ts": row.ts.isoformat(), "generation_kw": _kw(row.generation_kwh), "load_kw": _kw(row.load_kwh), "import_kw": _kw(row.import_kwh), "export_kw": _kw(row.export_kwh), }