Add data freshness indicator, TOU cost breakdown, and solar self-consumption stats to dashboard
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-13 07:45:05 +10:00
parent 8cbcd0155f
commit ad232447b8
3 changed files with 155 additions and 4 deletions

View file

@ -6,6 +6,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.costing.engine import IntervalReading, evaluate from app.costing.engine import IntervalReading, evaluate
from app.db.repositories import ( from app.db.repositories import (
get_current_plan, get_current_plan,
@ -94,15 +95,61 @@ def usage_summary(days: str = Query("30")) -> dict:
readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows] readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows]
net_revenue_dollars = round(-evaluate(current_plan, readings).total_dollars, 2) net_revenue_dollars = round(-evaluate(current_plan, readings).total_dollars, 2)
# Self-consumption: the share of solar generation that never left the
# property (used directly or, on a battery system, charged into it)
# rather than being exported. Solar coverage: the share of household
# load that was met by solar rather than drawn from the grid. Both are
# derived from metered totals (generation/load/import/export), which
# stay ground truth even on a battery system.
self_consumption_pct = None
if totals["generation_kwh"] > 0:
self_consumed_kwh = max(0.0, totals["generation_kwh"] - totals["export_kwh"])
self_consumption_pct = round(100 * self_consumed_kwh / totals["generation_kwh"], 1)
solar_coverage_pct = None
if totals["load_kwh"] > 0:
solar_covered_kwh = max(0.0, totals["load_kwh"] - totals["import_kwh"])
solar_coverage_pct = round(100 * solar_covered_kwh / totals["load_kwh"], 1)
return { return {
"days_with_data": len(dates_seen), "days_with_data": len(dates_seen),
"first_day": min(dates_seen), "first_day": min(dates_seen),
"last_day": max(dates_seen), "last_day": max(dates_seen),
"net_revenue_dollars": net_revenue_dollars, "net_revenue_dollars": net_revenue_dollars,
"self_consumption_pct": self_consumption_pct,
"solar_coverage_pct": solar_coverage_pct,
**{k: round(v, 2) for k, v in totals.items()}, **{k: round(v, 2) for k, v in totals.items()},
} }
@router.get("/tou_breakdown")
def tou_breakdown(days: str = Query("1")) -> dict:
"""Cost breakdown by the current plan's own usage components (e.g. Peak /
Off-Peak / Shoulder), so you can see where your spend is actually
landing rather than just the headline total."""
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)
if not rows or current_plan is None:
return {"available": False}
readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows]
result = evaluate(current_plan, readings)
components = [
{"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)}
for c in result.usage_breakdown
if c.kwh > 1e-9
]
return {
"available": True,
"days_with_data": result.days_with_data,
"components": sorted(components, key=lambda c: c["cost_dollars"], reverse=True),
"total_usage_cost_dollars": round(sum(c["cost_dollars"] for c in components), 2),
}
@router.get("/live") @router.get("/live")
def live_status() -> dict: def live_status() -> dict:
with get_session() as session: with get_session() as session:
@ -115,9 +162,17 @@ def live_status() -> dict:
def _kw(kwh: float) -> float | None: def _kw(kwh: float) -> float | None:
return round(kwh / step_hours, 3) if step_hours else None return round(kwh / step_hours, 3) if step_hours else None
# A single missed poll cycle is normal (transient API hiccup); flag
# staleness only once it's had time for two, since that's the pattern a
# genuinely broken poll (e.g. an invalidated OAuth token, which fails
# silently every cycle) actually produces.
age_minutes = (datetime.now(timezone.utc) - row.ts).total_seconds() / 60
stale = age_minutes > 2 * settings.isolarcloud_poll_interval_minutes
return { return {
"available": True, "available": True,
"ts": row.ts.isoformat(), "ts": row.ts.isoformat(),
"stale": stale,
"generation_kw": _kw(row.generation_kwh), "generation_kw": _kw(row.generation_kwh),
"load_kw": _kw(row.load_kwh), "load_kw": _kw(row.load_kwh),
"import_kw": _kw(row.import_kwh), "import_kw": _kw(row.import_kwh),

View file

@ -170,6 +170,53 @@ form.settings-form label.p-day-label input {
.live-label { fill: var(--muted); font-size: 13px; } .live-label { fill: var(--muted); font-size: 13px; }
.live-value { fill: var(--fg); font-size: 15px; font-weight: 600; } .live-value { fill: var(--fg); font-size: 15px; font-weight: 600; }
.freshness-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--muted);
}
.freshness-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--good);
flex-shrink: 0;
}
.freshness-badge.stale { color: var(--bad); }
.freshness-badge.stale .freshness-dot { background: var(--bad); }
.tou-bar-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.tou-bar-label { width: 110px; flex-shrink: 0; color: var(--muted); }
.tou-bar-track {
flex: 1;
height: 10px;
background: var(--border);
border-radius: 5px;
overflow: hidden;
}
.tou-bar-fill { height: 100%; background: var(--accent); border-radius: 5px; }
.tou-bar-amount { width: 130px; flex-shrink: 0; text-align: right; }
.pill-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.pill-toggle button {
margin: 0;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
background: var(--card-bg);
color: var(--fg);
border: none;
border-radius: 0;
}
.pill-toggle button.active { background: var(--accent); color: white; }
.flow-line { .flow-line {
animation: flow-dash 1s linear infinite; animation: flow-dash 1s linear infinite;
} }

View file

@ -5,7 +5,7 @@
<div class="card"> <div class="card">
<div style="display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:0.5rem;"> <div style="display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:0.5rem;">
<h3 style="margin:0;">Live system status</h3> <h3 style="margin:0;">Live system status</h3>
<span class="muted" id="live-updated">Loading&hellip;</span> <span class="freshness-badge" id="live-updated"><span class="freshness-dot"></span><span id="live-updated-text">Loading&hellip;</span></span>
</div> </div>
<div id="live-flow-wrap" style="margin-top:0.5rem;"> <div id="live-flow-wrap" style="margin-top:0.5rem;">
<svg id="live-flow" viewBox="0 0 600 230" style="width:100%; max-width:560px; display:block; margin:0 auto;"> <svg id="live-flow" viewBox="0 0 600 230" style="width:100%; max-width:560px; display:block; margin:0 auto;">
@ -83,6 +83,19 @@
<div class="muted">Loading summary&hellip;</div> <div class="muted">Loading summary&hellip;</div>
</div> </div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<h3 style="margin:0;">Cost by time-of-use period</h3>
<div class="pill-toggle" id="tou-range-toggle">
<button data-days="1" class="active">Today</button>
<button data-days="7">This week</button>
</div>
</div>
<div id="tou-breakdown-body" style="margin-top:0.75rem;">
<div class="muted">Loading&hellip;</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>
@ -116,13 +129,16 @@ function fmtAgo(isoTs) {
} }
async function loadLiveFlow() { async function loadLiveFlow() {
const el = document.getElementById('live-updated'); const badge = document.getElementById('live-updated');
const textEl = document.getElementById('live-updated-text');
const data = await fetch('/api/usage/live').then(r => r.json()); const data = await fetch('/api/usage/live').then(r => r.json());
if (!data.available) { if (!data.available) {
el.textContent = 'No data yet'; badge.classList.add('stale');
textEl.textContent = 'No data yet';
return; return;
} }
el.textContent = fmtAgo(data.ts); textEl.textContent = data.stale ? `${fmtAgo(data.ts)} (stale)` : fmtAgo(data.ts);
badge.classList.toggle('stale', !!data.stale);
const generation = data.generation_kw ?? 0; const generation = data.generation_kw ?? 0;
const load = data.load_kw ?? 0; const load = data.load_kw ?? 0;
@ -200,6 +216,10 @@ async function loadDashboard(days) {
const revenueHtml = revenue === null || revenue === undefined const revenueHtml = revenue === null || revenue === undefined
? '' ? ''
: `<div><div class="muted">Net revenue</div><strong class="${revenue >= 0 ? 'savings-positive' : 'savings-negative'}">${revenue >= 0 ? '+$' : '-$'}${Math.abs(revenue).toFixed(2)}</strong></div>`; : `<div><div class="muted">Net revenue</div><strong class="${revenue >= 0 ? 'savings-positive' : 'savings-negative'}">${revenue >= 0 ? '+$' : '-$'}${Math.abs(revenue).toFixed(2)}</strong></div>`;
const selfConsumptionHtml = summary.self_consumption_pct === null || summary.self_consumption_pct === undefined
? '' : `<div title="Share of solar generation that wasn't exported"><div class="muted">Self-consumption</div><strong>${summary.self_consumption_pct}%</strong></div>`;
const solarCoverageHtml = summary.solar_coverage_pct === null || summary.solar_coverage_pct === undefined
? '' : `<div title="Share of household load met by solar rather than the grid"><div class="muted">Solar coverage</div><strong>${summary.solar_coverage_pct}%</strong></div>`;
summaryEl.innerHTML = ` summaryEl.innerHTML = `
<div class="muted">${summary.first_day} &rarr; ${summary.last_day} (${summary.days_with_data} days with data)</div> <div class="muted">${summary.first_day} &rarr; ${summary.last_day} (${summary.days_with_data} days with data)</div>
<div style="display:flex; gap:2rem; margin-top:0.75rem; flex-wrap:wrap;"> <div style="display:flex; gap:2rem; margin-top:0.75rem; flex-wrap:wrap;">
@ -207,6 +227,8 @@ async function loadDashboard(days) {
<div><div class="muted">Consumed</div><strong>${summary.load_kwh} kWh</strong></div> <div><div class="muted">Consumed</div><strong>${summary.load_kwh} kWh</strong></div>
<div><div class="muted">Imported</div><strong>${summary.import_kwh} kWh</strong></div> <div><div class="muted">Imported</div><strong>${summary.import_kwh} kWh</strong></div>
<div><div class="muted">Exported</div><strong>${summary.export_kwh} kWh</strong></div> <div><div class="muted">Exported</div><strong>${summary.export_kwh} kWh</strong></div>
${selfConsumptionHtml}
${solarCoverageHtml}
${revenueHtml} ${revenueHtml}
</div> </div>
`; `;
@ -240,10 +262,37 @@ async function loadDashboard(days) {
}); });
} }
async function loadTouBreakdown(days) {
const body = document.getElementById('tou-breakdown-body');
const data = await fetch(`/api/usage/tou_breakdown?days=${days}`).then(r => r.json());
if (!data.available || !data.components.length) {
body.innerHTML = '<div class="muted">No usage cost data yet for this period.</div>';
return;
}
const maxCost = Math.max(...data.components.map(c => c.cost_dollars), 0.01);
body.innerHTML = data.components.map(c => `
<div class="tou-bar-row">
<div class="tou-bar-label">${c.label}</div>
<div class="tou-bar-track"><div class="tou-bar-fill" style="width:${Math.max(2, 100 * c.cost_dollars / maxCost)}%;"></div></div>
<div class="tou-bar-amount">$${c.cost_dollars.toFixed(2)} &middot; ${c.kwh.toFixed(1)} kWh</div>
</div>
`).join('') + `<div class="muted" style="margin-top:0.6rem;">Total usage cost: $${data.total_usage_cost_dollars.toFixed(2)} over ${data.days_with_data} day(s) &mdash; excludes supply charge and feed-in credit.</div>`;
}
const touToggle = document.getElementById('tou-range-toggle');
touToggle.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
touToggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadTouBreakdown(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);
loadLiveFlow(); loadLiveFlow();
setInterval(loadLiveFlow, FLOW_REFRESH_MS); setInterval(loadLiveFlow, FLOW_REFRESH_MS);
</script> </script>