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, }