power-compare/app/api/usage.py
dlawler489 ad232447b8
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
Add data freshness indicator, TOU cost breakdown, and solar self-consumption stats to dashboard
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 07:45:05 +10:00

183 lines
7.2 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.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,
}