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
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:
parent
353f154626
commit
c30165c153
3 changed files with 141 additions and 28 deletions
|
|
@ -33,6 +33,27 @@ def _resolve_start(session, days: str, end: datetime) -> datetime:
|
|||
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:
|
||||
if granularity == "month":
|
||||
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")
|
||||
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
|
||||
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)
|
||||
predates that feature -- simply returns no points here. Over a long or
|
||||
"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:
|
||||
start = _resolve_start(session, days, end)
|
||||
start, end = _resolve_range(session, days, start_date, end_date)
|
||||
rows = get_usage_series(session, start, end)
|
||||
|
||||
points = [
|
||||
all_points = [
|
||||
{
|
||||
"ts": row.ts.isoformat(),
|
||||
"soc_percent": row.battery_soc_percent,
|
||||
|
|
@ -185,23 +214,41 @@ def battery_history(days: str = Query("1")) -> dict:
|
|||
for row in rows
|
||||
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")
|
||||
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
|
||||
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)
|
||||
start, end = _resolve_range(session, days, start_date, end_date)
|
||||
rows = get_usage_series(session, start, end)
|
||||
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]
|
||||
if not battery_rows or current_plan is None:
|
||||
return {"available": False}
|
||||
return {"available": False, "range_days": range_days}
|
||||
|
||||
battery_readings = []
|
||||
for row in battery_rows:
|
||||
|
|
@ -220,6 +267,7 @@ def battery_value(days: str = Query("1")) -> dict:
|
|||
return {
|
||||
"available": True,
|
||||
"days_with_data": result.days_with_data,
|
||||
"range_days": range_days,
|
||||
"charged_kwh": round(result.charged_kwh, 2),
|
||||
"discharged_kwh": round(result.discharged_kwh, 2),
|
||||
"avoided_import_dollars": round(result.avoided_import_dollars, 2),
|
||||
|
|
|
|||
|
|
@ -207,6 +207,15 @@ form.settings-form label.p-day-label input {
|
|||
.tou-bar-fill-credit { background: var(--good); }
|
||||
.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 button {
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -99,12 +99,25 @@
|
|||
<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 style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<select id="battery-range-select">
|
||||
<option value="1" selected>Today</option>
|
||||
<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…</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 id="battery-coverage-note" class="muted" style="margin-top:0.4rem;"></div>
|
||||
<div class="chart-container" style="height:220px;">
|
||||
<canvas id="battery-soc-chart"></canvas>
|
||||
</div>
|
||||
|
|
@ -327,10 +340,17 @@ touToggle.addEventListener('click', (e) => {
|
|||
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([
|
||||
fetch(`/api/usage/battery_history?days=${days}`).then(r => r.json()),
|
||||
fetch(`/api/usage/battery_value?days=${days}`).then(r => r.json()),
|
||||
fetch(`/api/usage/battery_history?${qs}`).then(r => r.json()),
|
||||
fetch(`/api/usage/battery_value?${qs}`).then(r => r.json()),
|
||||
]);
|
||||
|
||||
const card = document.getElementById('battery-card');
|
||||
|
|
@ -343,7 +363,24 @@ async function loadBatterySection(days) {
|
|||
}
|
||||
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 d = new Date(p.ts);
|
||||
return d.toLocaleString('en-AU', {
|
||||
|
|
@ -391,7 +428,7 @@ async function loadBatterySection(days) {
|
|||
}
|
||||
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.'
|
||||
? '<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 = `
|
||||
<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><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>
|
||||
${approxNote}
|
||||
`;
|
||||
}
|
||||
|
||||
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 batteryRangeSelect = document.getElementById('battery-range-select');
|
||||
const batteryCustomRange = document.getElementById('battery-custom-range');
|
||||
const batteryStartDate = document.getElementById('battery-start-date');
|
||||
const batteryEndDate = document.getElementById('battery-end-date');
|
||||
const batteryApplyRange = document.getElementById('battery-apply-range');
|
||||
|
||||
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');
|
||||
|
|
@ -419,7 +475,7 @@ rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
|
|||
loadDashboard(rangeSelect.value);
|
||||
|
||||
loadTouBreakdown(1);
|
||||
loadBatterySection(1);
|
||||
loadBatterySection({ days: '1' });
|
||||
loadLiveFlow();
|
||||
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue