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.config import settings 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) # Self-consumption: the share of solar generation that never left the # property (used directly or, on a battery system, charged into it) # rather than being exported. Solar coverage: the share of household # load that was met by solar rather than drawn from the grid. Both are # derived from metered totals (generation/load/import/export), which # stay ground truth even on a battery system. self_consumption_pct = None if totals["generation_kwh"] > 0: self_consumed_kwh = max(0.0, totals["generation_kwh"] - totals["export_kwh"]) self_consumption_pct = round(100 * self_consumed_kwh / totals["generation_kwh"], 1) solar_coverage_pct = None if totals["load_kwh"] > 0: solar_covered_kwh = max(0.0, totals["load_kwh"] - totals["import_kwh"]) solar_coverage_pct = round(100 * solar_covered_kwh / totals["load_kwh"], 1) return { "days_with_data": len(dates_seen), "first_day": min(dates_seen), "last_day": max(dates_seen), "net_revenue_dollars": net_revenue_dollars, "self_consumption_pct": self_consumption_pct, "solar_coverage_pct": solar_coverage_pct, **{k: round(v, 2) for k, v in totals.items()}, } @router.get("/tou_breakdown") def tou_breakdown(days: str = Query("1")) -> dict: """Cost breakdown by the current plan's own usage components (e.g. Peak / Off-Peak / Shoulder), so you can see where your spend is actually landing rather than just the headline total.""" 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 or current_plan is None: return {"available": False} readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows] result = evaluate(current_plan, readings) components = [ {"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)} for c in result.usage_breakdown if c.kwh > 1e-9 ] return { "available": True, "days_with_data": result.days_with_data, "components": sorted(components, key=lambda c: c["cost_dollars"], reverse=True), "total_usage_cost_dollars": round(sum(c["cost_dollars"] for c in components), 2), } @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 # A single missed poll cycle is normal (transient API hiccup); flag # staleness only once it's had time for two, since that's the pattern a # genuinely broken poll (e.g. an invalidated OAuth token, which fails # silently every cycle) actually produces. age_minutes = (datetime.now(timezone.utc) - row.ts).total_seconds() / 60 stale = age_minutes > 2 * settings.isolarcloud_poll_interval_minutes return { "available": True, "ts": row.ts.isoformat(), "stale": stale, "generation_kw": _kw(row.generation_kwh), "load_kw": _kw(row.load_kwh), "import_kw": _kw(row.import_kwh), "export_kw": _kw(row.export_kwh), "battery_soc_percent": row.battery_soc_percent, "battery_charge_w": row.battery_charge_w, "battery_discharge_w": row.battery_discharge_w, }