power-compare/app/api/usage.py
dlawler489 9e66a9a97d
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
Add battery telemetry for plants with a hybrid inverter/ESS
Confirmed against Sungrow's own docs and live-tested against a real
battery plant: SOC and charge/discharge power live at the *device*
level (getDeviceRealTimeData against the hybrid inverter's ps_key,
device_type 14), not in the plant-aggregate points used elsewhere in
this client -- querying them via the plant-level endpoint just returns
null. Battery status is fetched once per live "today" poll (not
backfilled historically) and stored in three new nullable columns,
self-migrated on startup since there's no Alembic here.

Also fixes the dashboard's self-consumption line to derive from
load - import rather than generation - export, which overstated
"solar -> house" for battery systems (a chunk of that gap was going
into charging, not the house). The live flow diagram now shows a
Battery node (SOC + charge/discharge) that stays hidden entirely for
solar-only plants like Kallangur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:25:09 +10:00

128 lines
4.6 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),
"battery_soc_percent": row.battery_soc_percent,
"battery_charge_w": row.battery_charge_w,
"battery_discharge_w": row.battery_discharge_w,
}