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
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:
parent
8cbcd0155f
commit
ad232447b8
3 changed files with 155 additions and 4 deletions
|
|
@ -6,6 +6,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.db.repositories import (
|
||||
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]
|
||||
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 {
|
||||
"days_with_data": len(dates_seen),
|
||||
"first_day": min(dates_seen),
|
||||
"last_day": max(dates_seen),
|
||||
"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()},
|
||||
}
|
||||
|
||||
|
||||
@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")
|
||||
def live_status() -> dict:
|
||||
with get_session() as session:
|
||||
|
|
@ -115,9 +162,17 @@ def live_status() -> dict:
|
|||
def _kw(kwh: float) -> float | 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 {
|
||||
"available": True,
|
||||
"ts": row.ts.isoformat(),
|
||||
"stale": stale,
|
||||
"generation_kw": _kw(row.generation_kwh),
|
||||
"load_kw": _kw(row.load_kwh),
|
||||
"import_kw": _kw(row.import_kwh),
|
||||
|
|
|
|||
|
|
@ -170,6 +170,53 @@ form.settings-form label.p-day-label input {
|
|||
.live-label { fill: var(--muted); font-size: 13px; }
|
||||
.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 {
|
||||
animation: flow-dash 1s linear infinite;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<div class="card">
|
||||
<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>
|
||||
<span class="muted" id="live-updated">Loading…</span>
|
||||
<span class="freshness-badge" id="live-updated"><span class="freshness-dot"></span><span id="live-updated-text">Loading…</span></span>
|
||||
</div>
|
||||
<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;">
|
||||
|
|
@ -83,6 +83,19 @@
|
|||
<div class="muted">Loading summary…</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…</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>
|
||||
|
|
@ -116,13 +129,16 @@ function fmtAgo(isoTs) {
|
|||
}
|
||||
|
||||
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());
|
||||
if (!data.available) {
|
||||
el.textContent = 'No data yet';
|
||||
badge.classList.add('stale');
|
||||
textEl.textContent = 'No data yet';
|
||||
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 load = data.load_kw ?? 0;
|
||||
|
|
@ -200,6 +216,10 @@ async function loadDashboard(days) {
|
|||
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>`;
|
||||
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 = `
|
||||
<div class="muted">${summary.first_day} → ${summary.last_day} (${summary.days_with_data} days with data)</div>
|
||||
<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">Imported</div><strong>${summary.import_kwh} kWh</strong></div>
|
||||
<div><div class="muted">Exported</div><strong>${summary.export_kwh} kWh</strong></div>
|
||||
${selfConsumptionHtml}
|
||||
${solarCoverageHtml}
|
||||
${revenueHtml}
|
||||
</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)} · ${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) — 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');
|
||||
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
|
||||
loadDashboard(rangeSelect.value);
|
||||
|
||||
loadTouBreakdown(1);
|
||||
loadLiveFlow();
|
||||
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue