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 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:

View file

@ -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),
)

View file

@ -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)

View file

@ -96,6 +96,23 @@
</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 style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Generation / load / import / export</h3>
@ -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 = '<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');
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
loadDashboard(rangeSelect.value);
loadTouBreakdown(1);
loadBatterySection(1);
loadLiveFlow();
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
</script>