diff --git a/app/api/usage.py b/app/api/usage.py index e5e46cc..1003ee1 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException, Query from app.config import settings -from app.costing.engine import IntervalReading, evaluate +from app.costing.engine import BatteryReading, IntervalReading, evaluate, evaluate_battery_value from app.db.repositories import ( get_current_plan, get_earliest_reading_ts, @@ -163,6 +163,75 @@ def tou_breakdown(days: str = Query("1")) -> dict: } +@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: diff --git a/app/costing/engine.py b/app/costing/engine.py index ae95336..12d48b9 100644 --- a/app/costing/engine.py +++ b/app/costing/engine.py @@ -28,6 +28,28 @@ class IntervalReading: export_kwh: float +@dataclass +class BatteryReading: + ts: datetime # timezone-aware (UTC) + charge_kwh: float + discharge_kwh: float + + +@dataclass +class BatteryValueResult: + charged_kwh: float + discharged_kwh: float + # What the discharged energy would have cost to import instead, valued + # at the current plan's usage rate for whatever time of day it happened. + avoided_import_dollars: float + # What the charged energy would have earned as a feed-in credit had it + # been exported instead of stored -- the "cost" of charging the battery + # rather than exporting straight away. + foregone_export_dollars: float + net_value_dollars: float + days_with_data: int + + @dataclass class ComponentBreakdown: label: str @@ -242,3 +264,56 @@ def evaluate_bucketed( `bucket_readings`) so comparing many plans against the same usage data only pays the bucketing cost once.""" return _evaluate_core(plan, buckets, days_with_data, daily_export_kwh) + + +def _feed_in_rate_c(plan: TariffPlan, ctx: TimeContext) -> float | None: + if plan.tiered_feed_in: + # A tiered feed-in tariff's rate depends on that calendar day's + # *cumulative* export total, not the time of day, so it can't be + # matched via TimeContext the way flat/TOU components can. + # Approximating with the above-threshold (marginal) rate is the + # reasonable default for a solar household already exporting past + # the daily cap most days -- charging the battery instead of + # exporting a bit more almost always displaces balance-tier kWh, + # not capped-tier kWh. Flagged to the caller via + # `BatteryValueResult` so the UI can note it's an approximation. + return plan.tiered_feed_in.rate_above_c_per_kwh + component = _first_match(plan.feed_in_tariffs, ctx) + return component.rate_c_per_kwh if component else None + + +def evaluate_battery_value(plan: TariffPlan, readings: Iterable[BatteryReading]) -> BatteryValueResult: + """Estimates what a battery is actually worth: energy it discharged is + valued at the import rate it let you avoid, energy it charged is + "costed" at the feed-in credit you gave up by not exporting it instead. + Both sides are matched against the current plan's own time-of-use + windows for the moment each charge/discharge reading happened, the same + way `evaluate` matches usage/export readings. + """ + charged = discharged = avoided_import = foregone_export = 0.0 + days_with_data: set[str] = set() + for r in readings: + local_dt = r.ts.astimezone(BRISBANE_TZ) + days_with_data.add(local_dt.date().isoformat()) + ctx = TimeContext.from_datetime(local_dt) + + if r.discharge_kwh > 1e-9: + usage_component = _first_match(plan.usage_components, ctx) + if usage_component is not None: + avoided_import += r.discharge_kwh * usage_component.rate_c_per_kwh / 100 + discharged += r.discharge_kwh + + if r.charge_kwh > 1e-9: + feed_in_rate_c = _feed_in_rate_c(plan, ctx) + if feed_in_rate_c is not None: + foregone_export += r.charge_kwh * feed_in_rate_c / 100 + charged += r.charge_kwh + + return BatteryValueResult( + charged_kwh=charged, + discharged_kwh=discharged, + avoided_import_dollars=avoided_import, + foregone_export_dollars=foregone_export, + net_value_dollars=avoided_import - foregone_export, + days_with_data=len(days_with_data), + ) diff --git a/app/costing/tests/test_engine.py b/app/costing/tests/test_engine.py index dc7a5c3..74e8eb1 100644 --- a/app/costing/tests/test_engine.py +++ b/app/costing/tests/test_engine.py @@ -3,7 +3,7 @@ from zoneinfo import ZoneInfo import pytest -from app.costing.engine import IntervalReading, evaluate +from app.costing.engine import BatteryReading, IntervalReading, evaluate, evaluate_battery_value from app.costing.tariff_models import ( ALL_DAYS, FeedInComponent, @@ -290,3 +290,74 @@ def test_conditional_discount_kept_out_of_headline_total(): assert result.total_dollars == pytest.approx(10.00) assert result.potential_conditional_savings_dollars == pytest.approx(1.00) assert any("Pay on time discount" in note for note in result.modeling_notes) + + +def test_battery_value_flat_plan(): + plan = TariffPlan( + plan_id="flat-1", + retailer="Test Co", + plan_name="Flat Saver", + supply_charge_c_per_day=100, + usage_components=[UsageComponent(label="flat", rate_c_per_kwh=30)], + feed_in_tariffs=[FeedInComponent(label="fit", rate_c_per_kwh=8)], + ) + readings = [ + # Discharged 2 kWh at 9am -> avoided 2 * 30c = $0.60 of import. + BatteryReading(ts=bne(2026, 1, 1, 9), charge_kwh=0, discharge_kwh=2), + # Charged 3 kWh at 1pm -> gave up 3 * 8c = $0.24 of export credit. + BatteryReading(ts=bne(2026, 1, 1, 13), charge_kwh=3, discharge_kwh=0), + ] + + result = evaluate_battery_value(plan, readings) + + assert result.discharged_kwh == pytest.approx(2) + assert result.charged_kwh == pytest.approx(3) + assert result.avoided_import_dollars == pytest.approx(0.60) + assert result.foregone_export_dollars == pytest.approx(0.24) + assert result.net_value_dollars == pytest.approx(0.36) + assert result.days_with_data == 1 + + +def test_battery_value_time_of_use_plan_matches_each_readings_own_window(): + plan = TariffPlan( + plan_id="tou-1", + retailer="Test Co", + plan_name="TOU Plan", + supply_charge_c_per_day=0, + usage_components=[ + UsageComponent( + label="Peak", rate_c_per_kwh=50, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(16, 0), end_time=time(20, 0))], + ), + UsageComponent( + label="Off-peak", rate_c_per_kwh=20, + time_windows=[TimeWindow(days=ALL_DAYS, start_time=time(20, 0), end_time=time(16, 0))], + ), + ], + ) + readings = [ + # Discharging at 6pm (peak) is worth more than at 10am (off-peak). + BatteryReading(ts=bne(2026, 1, 1, 18), charge_kwh=0, discharge_kwh=1), + BatteryReading(ts=bne(2026, 1, 1, 10), charge_kwh=0, discharge_kwh=1), + ] + + result = evaluate_battery_value(plan, readings) + + assert result.avoided_import_dollars == pytest.approx(0.50 + 0.20) + + +def test_battery_value_tiered_feed_in_uses_above_threshold_rate(): + plan = TariffPlan( + plan_id="tiered-1", + retailer="Test Co", + plan_name="Tiered FiT", + supply_charge_c_per_day=0, + usage_components=[UsageComponent(label="flat", rate_c_per_kwh=30)], + tiered_feed_in=TieredFeedIn(daily_threshold_kwh=10, rate_below_c_per_kwh=9, rate_above_c_per_kwh=2), + ) + readings = [BatteryReading(ts=bne(2026, 1, 1, 13), charge_kwh=5, discharge_kwh=0)] + + result = evaluate_battery_value(plan, readings) + + # Approximated at the above-threshold (marginal) rate, not the capped one. + assert result.foregone_export_dollars == pytest.approx(5 * 0.02) diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index a8f1fcb..e135b67 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -96,6 +96,23 @@ + +

Generation / load / import / export

@@ -310,11 +327,99 @@ touToggle.addEventListener('click', (e) => { loadTouBreakdown(btn.dataset.days); }); +async function loadBatterySection(days) { + const [history, value] = await Promise.all([ + fetch(`/api/usage/battery_history?days=${days}`).then(r => r.json()), + fetch(`/api/usage/battery_value?days=${days}`).then(r => r.json()), + ]); + + const card = document.getElementById('battery-card'); + // No battery telemetry ever recorded -> this plant has no battery (or + // predates the feature); hide the whole card rather than show an + // always-empty one, same convention as the live flow diagram's battery node. + if (!history.available) { + card.style.display = 'none'; + return; + } + card.style.display = ''; + + const multiDay = days !== '1'; + const labels = history.points.map(p => { + const d = new Date(p.ts); + return d.toLocaleString('en-AU', { + day: multiDay ? '2-digit' : undefined, + month: multiDay ? '2-digit' : undefined, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + }); + + const ctx = document.getElementById('battery-soc-chart'); + if (window.batteryChart) window.batteryChart.destroy(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + window.batteryChart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [{ + label: 'Battery SOC %', + data: history.points.map(p => p.soc_percent), + borderColor: '#fbbf24', + backgroundColor: 'rgba(251, 191, 36, 0.15)', + fill: true, + pointRadius: 0, + borderWidth: 2, + tension: 0.2, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { ticks: { maxTicksLimit: 8, autoSkip: true } }, + y: { min: 0, max: 100, title: { display: true, text: 'SOC %' } }, + }, + }, + }); + + const valueBody = document.getElementById('battery-value-body'); + if (!value.available) { + valueBody.innerHTML = '
Not enough charge/discharge data yet to estimate value.
'; + return; + } + const net = value.net_value_dollars; + const approxNote = value.uses_tiered_feed_in_approximation + ? ' Export credit side is approximated: your plan\'s feed-in tariff depends on daily export volume, not time of day.' + : ''; + valueBody.innerHTML = ` +
+
Charged
${value.charged_kwh.toFixed(1)} kWh
+
Discharged
${value.discharged_kwh.toFixed(1)} kWh
+
Avoided import cost
+$${value.avoided_import_dollars.toFixed(2)}
+
Foregone export credit
-$${value.foregone_export_dollars.toFixed(2)}
+
Estimated battery value
${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}
+
+
Over ${value.days_with_data} day(s) with battery data.${approxNote}
+ `; +} + +const batteryToggle = document.getElementById('battery-range-toggle'); +batteryToggle.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + batteryToggle.querySelectorAll('button').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + loadBatterySection(btn.dataset.days); +}); + const rangeSelect = document.getElementById('range-select'); rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value)); loadDashboard(rangeSelect.value); loadTouBreakdown(1); +loadBatterySection(1); loadLiveFlow(); setInterval(loadLiveFlow, FLOW_REFRESH_MS);