power-compare/app/api/usage.py
dlawler489 8423f01014
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
Add trend sparklines, icon-based stat grid, and redraw the live flow diagram
- Period summary and battery stats now render as an icon-led, responsive
  stat grid (.stat-grid) instead of a flat flex row.
- Generation/load/import/export stats get a real inline sparkline from the
  same daily series already fetched for the bar chart. Self-consumption,
  solar coverage and net revenue get a direction-aware trend badge against
  an equal-length prior period (new `offset_days` param on
  /api/usage/summary shifts the trailing window back for this comparison).
- Live flow diagram: static "conduit" wires now stay visible underneath
  the animated overlay, flow lines redrawn as glowing round-dot particles
  (was a plain dashed line) whose speed now scales with the actual kW
  flowing, and each node's ring lights up in that flow's color while active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:49:39 +10:00

334 lines
14 KiB
Python

from __future__ import annotations
import math
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 BatteryReading, IntervalReading, evaluate, evaluate_battery_value
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 _resolve_range(
session, days: str, start_date: str | None, end_date: str | None
) -> tuple[datetime, datetime]:
"""Either a `days`/"all" preset (relative to now), or an explicit
`start_date`/`end_date` pair ("YYYY-MM-DD", inclusive, interpreted as
whole Brisbane-local calendar days) for a user-picked custom range."""
if start_date or end_date:
if not start_date or not end_date:
raise HTTPException(400, "start_date and end_date must both be given for a custom range")
try:
start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=BNE)
end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=BNE) + timedelta(days=1)
except ValueError:
raise HTTPException(400, "start_date/end_date must be in YYYY-MM-DD format")
if end <= start:
raise HTTPException(400, "end_date must be on or after start_date")
return start, end
end = datetime.now(timezone.utc)
return _resolve_start(session, days, end), end
def _range_days(start: datetime, end: datetime) -> int:
"""Number of calendar days a [start, end) range could span. Rounds up
(not truncates) the raw duration, since `start` isn't always exactly at
a day boundary -- "all time" starts at the first reading's actual
timestamp, which is very rarely local midnight -- and truncating there
would make `days_with_data` (a whole-calendar-day count) look bigger
than the range it's supposedly a fraction of. A day-aligned preset
(`days=N`, or a custom Y-M-D range) already lands on an exact multiple
of 24h, so rounding up is a no-op for those and only changes the
"all time" case."""
return max(1, math.ceil((end - start).total_seconds() / 86400))
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"), offset_days: int = Query(0)) -> dict:
"""`offset_days` shifts the whole trailing window back in time by that
many days (e.g. days=30&offset_days=30 is "the 30 days before the
current 30-day period") -- lets the dashboard fetch an equal-length
prior period for a simple trend comparison, without a separate
calendar-date endpoint."""
if offset_days < 0:
raise HTTPException(400, "offset_days must be >= 0")
end = datetime.now(timezone.utc) - timedelta(days=offset_days)
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) and feed-in components (solar export credit), so
you can see where your spend -- and your credit -- 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)
usage_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
]
# feed_in_breakdown's cost_dollars is already negative (a credit), same
# sign convention the plan comparison table uses.
feed_in_components = [
{"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)}
for c in result.feed_in_breakdown
if c.kwh > 1e-9
]
total_usage_cost = round(sum(c["cost_dollars"] for c in usage_components), 2)
total_feed_in_credit = round(sum(c["cost_dollars"] for c in feed_in_components), 2)
return {
"available": True,
"days_with_data": result.days_with_data,
"usage_components": sorted(usage_components, key=lambda c: c["cost_dollars"], reverse=True),
"feed_in_components": sorted(feed_in_components, key=lambda c: c["cost_dollars"]),
"total_usage_cost_dollars": total_usage_cost,
"total_feed_in_credit_dollars": total_feed_in_credit,
"net_usage_and_export_dollars": round(total_usage_cost + total_feed_in_credit, 2),
}
_MAX_BATTERY_CHART_POINTS = 2000
@router.get("/battery_history")
def battery_history(
days: str = Query("1"), start_date: str | None = Query(None), end_date: str | None = Query(None)
) -> dict:
"""SOC/charge/discharge curve for plants with a battery. Battery
telemetry is only ever captured on the live "today" poll (see
`IntervalReadingRow.battery_soc_percent`), never backfilled for
historical rows, so a plant with no battery -- or one whose data
predates that feature -- simply returns no points here. Over a long or
"all time" range, most of that range typically has no battery data at
all (e.g. it predates the battery being installed, or this feature
shipping) -- `days_with_data`/`range_days` let the UI say so plainly
rather than implying a full, continuous history."""
with get_session() as session:
start, end = _resolve_range(session, days, start_date, end_date)
rows = get_usage_series(session, start, end)
all_points = [
{
"ts": row.ts.isoformat(),
"soc_percent": row.battery_soc_percent,
"charge_w": row.battery_charge_w,
"discharge_w": row.battery_discharge_w,
}
for row in rows
if row.battery_soc_percent is not None
]
days_with_data = len({row.ts.astimezone(BNE).date() for row in rows if row.battery_soc_percent is not None})
range_days = _range_days(start, end)
points = all_points
if len(points) > _MAX_BATTERY_CHART_POINTS:
# Long ranges (a year, "all time") can accumulate more points than a
# line chart can usefully render -- evenly subsample rather than
# truncating, so the shape of the whole range is still represented.
step = len(points) / _MAX_BATTERY_CHART_POINTS
points = [points[int(i * step)] for i in range(_MAX_BATTERY_CHART_POINTS)]
return {
"available": bool(all_points),
"points": points,
"days_with_data": days_with_data,
"range_days": range_days,
}
@router.get("/battery_value")
def battery_value(
days: str = Query("1"), start_date: str | None = Query(None), end_date: str | None = Query(None)
) -> dict:
"""What the battery actually saved you: discharged energy valued at the
import rate it avoided, minus charged energy valued at the feed-in
credit it gave up, both matched to the current plan's own TOU windows."""
with get_session() as session:
start, end = _resolve_range(session, days, start_date, end_date)
rows = get_usage_series(session, start, end)
current_plan = get_current_plan(session)
range_days = _range_days(start, end)
battery_rows = [r for r in rows if r.battery_charge_w is not None or r.battery_discharge_w is not None]
if not battery_rows or current_plan is None:
return {"available": False, "range_days": range_days}
battery_readings = []
for row in battery_rows:
step_hours = (row.resolution_minutes or 0) / 60
if not step_hours:
continue
battery_readings.append(
BatteryReading(
ts=row.ts,
charge_kwh=(row.battery_charge_w or 0.0) * step_hours / 1000,
discharge_kwh=(row.battery_discharge_w or 0.0) * step_hours / 1000,
)
)
result = evaluate_battery_value(current_plan, battery_readings)
return {
"available": True,
"days_with_data": result.days_with_data,
"range_days": range_days,
"charged_kwh": round(result.charged_kwh, 2),
"discharged_kwh": round(result.discharged_kwh, 2),
"avoided_import_dollars": round(result.avoided_import_dollars, 2),
"foregone_export_dollars": round(result.foregone_export_dollars, 2),
"net_value_dollars": round(result.net_value_dollars, 2),
# The foregone-export side is only an approximation when the plan's
# feed-in tariff is tiered by daily volume rather than flat/TOU --
# see `_feed_in_rate_c` in engine.py.
"uses_tiered_feed_in_approximation": current_plan.tiered_feed_in is not None,
}
@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,
}