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 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:
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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…</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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue