power-compare/app/api/usage.py
dlawler489 353f154626
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20s
Add battery SOC history chart and estimated battery value to dashboard
New card (hidden entirely for solar-only plants) shows: an SOC curve over
Today/Last 3 days/This week, and an estimate of what the battery actually
saved -- discharged energy valued at the import rate it avoided, minus
charged energy valued at the feed-in credit given up, both matched against
the current plan's own time-of-use windows.

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

265 lines
11 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 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 _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) 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),
}
@router.get("/battery_history")
def battery_history(days: str = Query("1")) -> 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."""
end = datetime.now(timezone.utc)
with get_session() as session:
start = _resolve_start(session, days, end)
rows = get_usage_series(session, start, end)
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
]
return {"available": bool(points), "points": points}
@router.get("/battery_value")
def battery_value(days: str = Query("1")) -> 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."""
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)
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}
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,
"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,
}