All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
New /api/usage/live endpoint derives instantaneous-ish kW from the most recent interval reading, shown as a solar -> house/grid flow diagram that updates every 30s and flips direction/color between exporting and importing. /api/usage/summary now also reports net revenue for the selected period, reusing the existing costing engine against the current plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
125 lines
4.5 KiB
Python
125 lines
4.5 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.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),
|
|
}
|