Add longer battery ranges and a custom date-range picker
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s

Extends the battery card's range beyond Today/3 days/This week to Last 30
days, Last 365 days, All time, and a user-picked custom start/end date.
Since battery telemetry is only ever captured on the live "today" poll and
never backfilled, a long or all-time range typically has real gaps -- the
card now shows how many of the requested days actually have battery data
rather than implying continuous history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-13 11:10:20 +10:00
parent 353f154626
commit c30165c153
3 changed files with 141 additions and 28 deletions

View file

@ -33,6 +33,27 @@ def _resolve_start(session, days: str, end: datetime) -> datetime:
return end - timedelta(days=n) return end - timedelta(days=n)
def _resolve_range(
session, days: str, start_date: str | None, end_date: str | None
) -> tuple[datetime, datetime]:
"""Either a `days`/"all" preset (relative to now), or an explicit
`start_date`/`end_date` pair ("YYYY-MM-DD", inclusive, interpreted as
whole Brisbane-local calendar days) for a user-picked custom range."""
if start_date or end_date:
if not start_date or not end_date:
raise HTTPException(400, "start_date and end_date must both be given for a custom range")
try:
start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=BNE)
end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=BNE) + timedelta(days=1)
except ValueError:
raise HTTPException(400, "start_date/end_date must be in YYYY-MM-DD format")
if end <= start:
raise HTTPException(400, "end_date must be on or after start_date")
return start, end
end = datetime.now(timezone.utc)
return _resolve_start(session, days, end), end
def _bucket_key(local_date, granularity: str) -> str: def _bucket_key(local_date, granularity: str) -> str:
if granularity == "month": if granularity == "month":
return local_date.replace(day=1).isoformat() return local_date.replace(day=1).isoformat()
@ -163,19 +184,27 @@ def tou_breakdown(days: str = Query("1")) -> dict:
} }
_MAX_BATTERY_CHART_POINTS = 2000
@router.get("/battery_history") @router.get("/battery_history")
def battery_history(days: str = Query("1")) -> dict: def battery_history(
days: str = Query("1"), start_date: str | None = Query(None), end_date: str | None = Query(None)
) -> dict:
"""SOC/charge/discharge curve for plants with a battery. Battery """SOC/charge/discharge curve for plants with a battery. Battery
telemetry is only ever captured on the live "today" poll (see telemetry is only ever captured on the live "today" poll (see
`IntervalReadingRow.battery_soc_percent`), never backfilled for `IntervalReadingRow.battery_soc_percent`), never backfilled for
historical rows, so a plant with no battery -- or one whose data historical rows, so a plant with no battery -- or one whose data
predates that feature -- simply returns no points here.""" predates that feature -- simply returns no points here. Over a long or
end = datetime.now(timezone.utc) "all time" range, most of that range typically has no battery data at
all (e.g. it predates the battery being installed, or this feature
shipping) -- `days_with_data`/`range_days` let the UI say so plainly
rather than implying a full, continuous history."""
with get_session() as session: with get_session() as session:
start = _resolve_start(session, days, end) start, end = _resolve_range(session, days, start_date, end_date)
rows = get_usage_series(session, start, end) rows = get_usage_series(session, start, end)
points = [ all_points = [
{ {
"ts": row.ts.isoformat(), "ts": row.ts.isoformat(),
"soc_percent": row.battery_soc_percent, "soc_percent": row.battery_soc_percent,
@ -185,23 +214,41 @@ def battery_history(days: str = Query("1")) -> dict:
for row in rows for row in rows
if row.battery_soc_percent is not None if row.battery_soc_percent is not None
] ]
return {"available": bool(points), "points": points} days_with_data = len({row.ts.astimezone(BNE).date() for row in rows if row.battery_soc_percent is not None})
range_days = max(1, (end - start).days)
points = all_points
if len(points) > _MAX_BATTERY_CHART_POINTS:
# Long ranges (a year, "all time") can accumulate more points than a
# line chart can usefully render -- evenly subsample rather than
# truncating, so the shape of the whole range is still represented.
step = len(points) / _MAX_BATTERY_CHART_POINTS
points = [points[int(i * step)] for i in range(_MAX_BATTERY_CHART_POINTS)]
return {
"available": bool(all_points),
"points": points,
"days_with_data": days_with_data,
"range_days": range_days,
}
@router.get("/battery_value") @router.get("/battery_value")
def battery_value(days: str = Query("1")) -> dict: def battery_value(
days: str = Query("1"), start_date: str | None = Query(None), end_date: str | None = Query(None)
) -> dict:
"""What the battery actually saved you: discharged energy valued at the """What the battery actually saved you: discharged energy valued at the
import rate it avoided, minus charged energy valued at the feed-in 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.""" credit it gave up, both matched to the current plan's own TOU windows."""
end = datetime.now(timezone.utc)
with get_session() as session: with get_session() as session:
start = _resolve_start(session, days, end) start, end = _resolve_range(session, days, start_date, end_date)
rows = get_usage_series(session, start, end) rows = get_usage_series(session, start, end)
current_plan = get_current_plan(session) current_plan = get_current_plan(session)
range_days = max(1, (end - start).days)
battery_rows = [r for r in rows if r.battery_charge_w is not None or r.battery_discharge_w is not None] 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: if not battery_rows or current_plan is None:
return {"available": False} return {"available": False, "range_days": range_days}
battery_readings = [] battery_readings = []
for row in battery_rows: for row in battery_rows:
@ -220,6 +267,7 @@ def battery_value(days: str = Query("1")) -> dict:
return { return {
"available": True, "available": True,
"days_with_data": result.days_with_data, "days_with_data": result.days_with_data,
"range_days": range_days,
"charged_kwh": round(result.charged_kwh, 2), "charged_kwh": round(result.charged_kwh, 2),
"discharged_kwh": round(result.discharged_kwh, 2), "discharged_kwh": round(result.discharged_kwh, 2),
"avoided_import_dollars": round(result.avoided_import_dollars, 2), "avoided_import_dollars": round(result.avoided_import_dollars, 2),

View file

@ -207,6 +207,15 @@ form.settings-form label.p-day-label input {
.tou-bar-fill-credit { background: var(--good); } .tou-bar-fill-credit { background: var(--good); }
.tou-bar-amount { width: 130px; flex-shrink: 0; text-align: right; } .tou-bar-amount { width: 130px; flex-shrink: 0; text-align: right; }
select, input[type="date"] {
background: var(--card-bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
}
.pill-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } .pill-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.pill-toggle button { .pill-toggle button {
margin: 0; margin: 0;

View file

@ -99,12 +99,25 @@
<div class="card" id="battery-card" style="display:none;"> <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;"> <div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<h3 style="margin:0;">Battery</h3> <h3 style="margin:0;">Battery</h3>
<div class="pill-toggle" id="battery-range-toggle"> <div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
<button data-days="1" class="active">Today</button> <select id="battery-range-select">
<button data-days="3">Last 3 days</button> <option value="1" selected>Today</option>
<button data-days="7">This week</button> <option value="3">Last 3 days</option>
<option value="7">This week</option>
<option value="30">Last 30 days</option>
<option value="365">Last 365 days</option>
<option value="all">All time</option>
<option value="custom">Custom range&hellip;</option>
</select>
<span id="battery-custom-range" style="display:none; gap:0.4rem; align-items:center;">
<input type="date" id="battery-start-date">
<span class="muted">to</span>
<input type="date" id="battery-end-date">
<button id="battery-apply-range" style="margin:0; padding:0.35rem 0.7rem; font-size:0.85rem;">Apply</button>
</span>
</div> </div>
</div> </div>
<div id="battery-coverage-note" class="muted" style="margin-top:0.4rem;"></div>
<div class="chart-container" style="height:220px;"> <div class="chart-container" style="height:220px;">
<canvas id="battery-soc-chart"></canvas> <canvas id="battery-soc-chart"></canvas>
</div> </div>
@ -327,10 +340,17 @@ touToggle.addEventListener('click', (e) => {
loadTouBreakdown(btn.dataset.days); loadTouBreakdown(btn.dataset.days);
}); });
async function loadBatterySection(days) { function batteryQueryString(range) {
return range.startDate
? `start_date=${range.startDate}&end_date=${range.endDate}`
: `days=${range.days}`;
}
async function loadBatterySection(range) {
const qs = batteryQueryString(range);
const [history, value] = await Promise.all([ const [history, value] = await Promise.all([
fetch(`/api/usage/battery_history?days=${days}`).then(r => r.json()), fetch(`/api/usage/battery_history?${qs}`).then(r => r.json()),
fetch(`/api/usage/battery_value?days=${days}`).then(r => r.json()), fetch(`/api/usage/battery_value?${qs}`).then(r => r.json()),
]); ]);
const card = document.getElementById('battery-card'); const card = document.getElementById('battery-card');
@ -343,7 +363,24 @@ async function loadBatterySection(days) {
} }
card.style.display = ''; card.style.display = '';
const multiDay = days !== '1'; const coverageNote = document.getElementById('battery-coverage-note');
if (history.days_with_data < history.range_days) {
// Battery telemetry is only ever captured on the live "today" poll,
// never backfilled -- so a long or "all time" range will usually have
// real gaps (before the battery existed, before this feature shipped,
// or a day the system was offline). Say so plainly rather than let a
// sparse chart look like a data bug.
coverageNote.textContent = `${history.days_with_data} of ${history.range_days} day(s) in this range have battery data.`;
} else {
coverageNote.textContent = `${history.days_with_data} day(s) of battery data.`;
}
// Multi-day spans get a date in the label; a single day just gets the
// time -- based on what the data actually spans, not the range picked,
// since a long range with sparse data can still resolve to under a day.
const first = history.points[0] ? new Date(history.points[0].ts) : null;
const last = history.points.length ? new Date(history.points[history.points.length - 1].ts) : null;
const multiDay = first && last && (last - first) > 24 * 60 * 60 * 1000;
const labels = history.points.map(p => { const labels = history.points.map(p => {
const d = new Date(p.ts); const d = new Date(p.ts);
return d.toLocaleString('en-AU', { return d.toLocaleString('en-AU', {
@ -391,7 +428,7 @@ async function loadBatterySection(days) {
} }
const net = value.net_value_dollars; const net = value.net_value_dollars;
const approxNote = value.uses_tiered_feed_in_approximation 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.' ? '<div class="muted" style="margin-top:0.5rem;">Export credit side is approximated: your plan\'s feed-in tariff depends on daily export volume, not time of day.</div>'
: ''; : '';
valueBody.innerHTML = ` valueBody.innerHTML = `
<div style="display:flex; gap:2rem; flex-wrap:wrap;"> <div style="display:flex; gap:2rem; flex-wrap:wrap;">
@ -401,17 +438,36 @@ async function loadBatterySection(days) {
<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 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">Estimated battery value</div><strong class="${net >= 0 ? 'savings-positive' : 'savings-negative'}">${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}</strong></div>
</div> </div>
<div class="muted" style="margin-top:0.5rem;">Over ${value.days_with_data} day(s) with battery data.${approxNote}</div> ${approxNote}
`; `;
} }
const batteryToggle = document.getElementById('battery-range-toggle'); const batteryRangeSelect = document.getElementById('battery-range-select');
batteryToggle.addEventListener('click', (e) => { const batteryCustomRange = document.getElementById('battery-custom-range');
const btn = e.target.closest('button'); const batteryStartDate = document.getElementById('battery-start-date');
if (!btn) return; const batteryEndDate = document.getElementById('battery-end-date');
batteryToggle.querySelectorAll('button').forEach(b => b.classList.remove('active')); const batteryApplyRange = document.getElementById('battery-apply-range');
btn.classList.add('active');
loadBatterySection(btn.dataset.days); batteryRangeSelect.addEventListener('change', () => {
if (batteryRangeSelect.value === 'custom') {
batteryCustomRange.style.display = 'inline-flex';
// Default the pickers to a sensible starting point (last 30 days)
// rather than leaving them empty with nothing to Apply yet.
if (!batteryEndDate.value) {
const today = new Date();
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
batteryEndDate.value = today.toISOString().slice(0, 10);
batteryStartDate.value = monthAgo.toISOString().slice(0, 10);
}
return;
}
batteryCustomRange.style.display = 'none';
loadBatterySection({ days: batteryRangeSelect.value });
});
batteryApplyRange.addEventListener('click', () => {
if (!batteryStartDate.value || !batteryEndDate.value) return;
loadBatterySection({ startDate: batteryStartDate.value, endDate: batteryEndDate.value });
}); });
const rangeSelect = document.getElementById('range-select'); const rangeSelect = document.getElementById('range-select');
@ -419,7 +475,7 @@ rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
loadDashboard(rangeSelect.value); loadDashboard(rangeSelect.value);
loadTouBreakdown(1); loadTouBreakdown(1);
loadBatterySection(1); loadBatterySection({ days: '1' });
loadLiveFlow(); loadLiveFlow();
setInterval(loadLiveFlow, FLOW_REFRESH_MS); setInterval(loadLiveFlow, FLOW_REFRESH_MS);
</script> </script>