All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
183 lines
7.2 KiB
Python
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,
|
|
}
|