Add battery SOC history chart and estimated battery value to dashboard
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20s

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>
This commit is contained in:
dlawler489 2026-09-13 09:14:07 +10:00
parent 918ea39e8d
commit 353f154626
4 changed files with 322 additions and 2 deletions

View file

@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from app.config import settings 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 ( from app.db.repositories import (
get_current_plan, get_current_plan,
get_earliest_reading_ts, 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") @router.get("/live")
def live_status() -> dict: def live_status() -> dict:
with get_session() as session: with get_session() as session:

View file

@ -28,6 +28,28 @@ class IntervalReading:
export_kwh: float 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 @dataclass
class ComponentBreakdown: class ComponentBreakdown:
label: str label: str
@ -242,3 +264,56 @@ def evaluate_bucketed(
`bucket_readings`) so comparing many plans against the same usage data `bucket_readings`) so comparing many plans against the same usage data
only pays the bucketing cost once.""" only pays the bucketing cost once."""
return _evaluate_core(plan, buckets, days_with_data, daily_export_kwh) 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),
)

View file

@ -3,7 +3,7 @@ from zoneinfo import ZoneInfo
import pytest 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 ( from app.costing.tariff_models import (
ALL_DAYS, ALL_DAYS,
FeedInComponent, FeedInComponent,
@ -290,3 +290,74 @@ def test_conditional_discount_kept_out_of_headline_total():
assert result.total_dollars == pytest.approx(10.00) assert result.total_dollars == pytest.approx(10.00)
assert result.potential_conditional_savings_dollars == pytest.approx(1.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) 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)

View file

@ -96,6 +96,23 @@
</div> </div>
</div> </div>
<div class="card" id="battery-card" style="display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<h3 style="margin:0;">Battery</h3>
<div class="pill-toggle" id="battery-range-toggle">
<button data-days="1" class="active">Today</button>
<button data-days="3">Last 3 days</button>
<button data-days="7">This week</button>
</div>
</div>
<div class="chart-container" style="height:220px;">
<canvas id="battery-soc-chart"></canvas>
</div>
<div id="battery-value-body" style="margin-top:0.75rem;">
<div class="muted">Loading&hellip;</div>
</div>
</div>
<div class="card"> <div class="card">
<div style="display:flex; justify-content:space-between; align-items:center;"> <div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Generation / load / import / export</h3> <h3 style="margin:0;">Generation / load / import / export</h3>
@ -310,11 +327,99 @@ touToggle.addEventListener('click', (e) => {
loadTouBreakdown(btn.dataset.days); 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 = '<div class="muted">Not enough charge/discharge data yet to estimate value.</div>';
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 = `
<div style="display:flex; gap:2rem; flex-wrap:wrap;">
<div><div class="muted">Charged</div><strong>${value.charged_kwh.toFixed(1)} kWh</strong></div>
<div><div class="muted">Discharged</div><strong>${value.discharged_kwh.toFixed(1)} kWh</strong></div>
<div title="What the discharged energy would have cost to import instead"><div class="muted">Avoided import cost</div><strong class="savings-positive">+$${value.avoided_import_dollars.toFixed(2)}</strong></div>
<div title="What the charged energy would have earned as feed-in credit had it been exported instead"><div class="muted">Foregone export credit</div><strong class="savings-negative">-$${value.foregone_export_dollars.toFixed(2)}</strong></div>
<div><div class="muted">Estimated battery value</div><strong class="${net >= 0 ? 'savings-positive' : 'savings-negative'}">${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}</strong></div>
</div>
<div class="muted" style="margin-top:0.5rem;">Over ${value.days_with_data} day(s) with battery data.${approxNote}</div>
`;
}
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'); const rangeSelect = document.getElementById('range-select');
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value)); rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
loadDashboard(rangeSelect.value); loadDashboard(rangeSelect.value);
loadTouBreakdown(1); loadTouBreakdown(1);
loadBatterySection(1);
loadLiveFlow(); loadLiveFlow();
setInterval(loadLiveFlow, FLOW_REFRESH_MS); setInterval(loadLiveFlow, FLOW_REFRESH_MS);
</script> </script>