All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s
Extends the battery card's range beyond Today/3 days/This week to Last 30 days, Last 365 days, All time, and a user-picked custom start/end date. Since battery telemetry is only ever captured on the live "today" poll and never backfilled, a long or all-time range typically has real gaps -- the card now shows how many of the requested days actually have battery data rather than implying continuous history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
313 lines
13 KiB
Python
313 lines
13 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 _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 _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),
|
|
}
|
|
|
|
|
|
_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 = max(1, (end - start).days)
|
|
|
|
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 = max(1, (end - start).days)
|
|
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,
|
|
}
|