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
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:
parent
918ea39e8d
commit
353f154626
4 changed files with 322 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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…</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue