Add live power-flow diagram and net revenue to the dashboard
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
New /api/usage/live endpoint derives instantaneous-ish kW from the most recent interval reading, shown as a solar -> house/grid flow diagram that updates every 30s and flips direction/color between exporting and importing. /api/usage/summary now also reports net revenue for the selected period, reusing the existing costing engine against the current plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5ffd91a643
commit
3d7c8d9ca0
4 changed files with 177 additions and 1 deletions
|
|
@ -6,7 +6,13 @@ from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from app.db.repositories import get_earliest_reading_ts, get_usage_series
|
from app.costing.engine import IntervalReading, evaluate
|
||||||
|
from app.db.repositories import (
|
||||||
|
get_current_plan,
|
||||||
|
get_earliest_reading_ts,
|
||||||
|
get_latest_reading,
|
||||||
|
get_usage_series,
|
||||||
|
)
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/usage", tags=["usage"])
|
router = APIRouter(prefix="/api/usage", tags=["usage"])
|
||||||
|
|
@ -66,6 +72,7 @@ def usage_summary(days: str = Query("30")) -> dict:
|
||||||
with get_session() as session:
|
with get_session() as session:
|
||||||
start = _resolve_start(session, days, end)
|
start = _resolve_start(session, days, end)
|
||||||
rows = get_usage_series(session, start, end)
|
rows = get_usage_series(session, start, end)
|
||||||
|
current_plan = get_current_plan(session)
|
||||||
if not rows:
|
if not rows:
|
||||||
return {"days_with_data": 0}
|
return {"days_with_data": 0}
|
||||||
|
|
||||||
|
|
@ -78,9 +85,41 @@ def usage_summary(days: str = Query("30")) -> dict:
|
||||||
totals["import_kwh"] += row.import_kwh
|
totals["import_kwh"] += row.import_kwh
|
||||||
totals["export_kwh"] += row.export_kwh
|
totals["export_kwh"] += row.export_kwh
|
||||||
|
|
||||||
|
net_revenue_dollars = None
|
||||||
|
if current_plan is not None:
|
||||||
|
# Reuses the same costing engine the plan comparison runs on --
|
||||||
|
# "net revenue" is just the negative of what the current plan would
|
||||||
|
# bill for this period (positive when export credit outweighs the
|
||||||
|
# cost of imports).
|
||||||
|
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)
|
||||||
|
|
||||||
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,
|
||||||
**{k: round(v, 2) for k, v in totals.items()},
|
**{k: round(v, 2) for k, v in totals.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/live")
|
||||||
|
def live_status() -> dict:
|
||||||
|
with get_session() as session:
|
||||||
|
row = get_latest_reading(session)
|
||||||
|
if row is None:
|
||||||
|
return {"available": False}
|
||||||
|
|
||||||
|
step_hours = row.resolution_minutes / 60 if row.resolution_minutes else None
|
||||||
|
|
||||||
|
def _kw(kwh: float) -> float | None:
|
||||||
|
return round(kwh / step_hours, 3) if step_hours else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"ts": row.ts.isoformat(),
|
||||||
|
"generation_kw": _kw(row.generation_kwh),
|
||||||
|
"load_kw": _kw(row.load_kwh),
|
||||||
|
"import_kw": _kw(row.import_kwh),
|
||||||
|
"export_kw": _kw(row.export_kwh),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,11 @@ def get_earliest_reading_ts(session: Session) -> datetime | None:
|
||||||
return session.scalar(select(func.min(IntervalReadingRow.ts)))
|
return session.scalar(select(func.min(IntervalReadingRow.ts)))
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_reading(session: Session) -> IntervalReadingRow | None:
|
||||||
|
stmt = select(IntervalReadingRow).order_by(IntervalReadingRow.ts.desc()).limit(1)
|
||||||
|
return session.scalars(stmt).first()
|
||||||
|
|
||||||
|
|
||||||
def get_usage_series(session: Session, start: datetime, end: datetime) -> list[IntervalReadingRow]:
|
def get_usage_series(session: Session, start: datetime, end: datetime) -> list[IntervalReadingRow]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(IntervalReadingRow)
|
select(IntervalReadingRow)
|
||||||
|
|
|
||||||
|
|
@ -164,3 +164,13 @@ form.settings-form label.p-day-label input {
|
||||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||||
.chart-container { position: relative; height: 320px; margin-top: 0.75rem; }
|
.chart-container { position: relative; height: 320px; margin-top: 0.75rem; }
|
||||||
.notes { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; }
|
.notes { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
.live-label { fill: var(--muted); font-size: 13px; }
|
||||||
|
.live-value { fill: var(--fg); font-size: 15px; font-weight: 600; }
|
||||||
|
|
||||||
|
.flow-line {
|
||||||
|
animation: flow-dash 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes flow-dash {
|
||||||
|
to { stroke-dashoffset: -12; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,65 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Usage Dashboard</h1>
|
<h1>Usage Dashboard</h1>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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;">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="context-stroke"></path>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Solar -> House -->
|
||||||
|
<path id="flow-solar-house" d="M 260 70 C 200 130, 180 150, 150 175" fill="none" stroke="var(--accent)" stroke-width="3"
|
||||||
|
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
|
||||||
|
<!-- Solar -> Grid (export) / Grid -> House (import), same path, direction/color/arrow flipped by JS -->
|
||||||
|
<path id="flow-solar-grid" d="M 340 70 C 400 130, 420 150, 450 175" fill="none" stroke="var(--good)" stroke-width="3"
|
||||||
|
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
|
||||||
|
|
||||||
|
<!-- Solar node -->
|
||||||
|
<g transform="translate(300 55)">
|
||||||
|
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
|
||||||
|
<g stroke="var(--fg)" stroke-width="2" fill="none" stroke-linecap="round">
|
||||||
|
<circle r="8" cy="0"></circle>
|
||||||
|
<line x1="0" y1="-18" x2="0" y2="-13"></line>
|
||||||
|
<line x1="0" y1="13" x2="0" y2="18"></line>
|
||||||
|
<line x1="-18" y1="0" x2="-13" y2="0"></line>
|
||||||
|
<line x1="13" y1="0" x2="18" y2="0"></line>
|
||||||
|
<line x1="-12.7" y1="-12.7" x2="-9.2" y2="-9.2"></line>
|
||||||
|
<line x1="9.2" y1="9.2" x2="12.7" y2="12.7"></line>
|
||||||
|
<line x1="12.7" y1="-12.7" x2="9.2" y2="-9.2"></line>
|
||||||
|
<line x1="-9.2" y1="9.2" x2="-12.7" y2="12.7"></line>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="300" y="10" text-anchor="middle" class="live-label">Solar</text>
|
||||||
|
<text id="live-generation" x="300" y="107" text-anchor="middle" class="live-value">—</text>
|
||||||
|
|
||||||
|
<!-- House node -->
|
||||||
|
<g transform="translate(120 195)">
|
||||||
|
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
|
||||||
|
<path d="M -14,4 L -14,-4 L 0,-16 L 14,-4 L 14,4 L 8,4 L 8,14 L -8,14 L -8,4 Z"
|
||||||
|
fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
|
||||||
|
</g>
|
||||||
|
<text x="120" y="163" text-anchor="middle" class="live-label">House</text>
|
||||||
|
<text id="live-load" x="120" y="230" text-anchor="middle" class="live-value">—</text>
|
||||||
|
|
||||||
|
<!-- Grid node -->
|
||||||
|
<g transform="translate(480 195)">
|
||||||
|
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
|
||||||
|
<path d="M 2,-16 L -8,2 L -1,2 L -3,16 L 10,-4 L 2,-4 Z"
|
||||||
|
fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
|
||||||
|
</g>
|
||||||
|
<text x="480" y="163" text-anchor="middle" class="live-label">Grid</text>
|
||||||
|
<text id="live-grid" x="480" y="230" text-anchor="middle" class="live-value">—</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" id="summary-card">
|
<div class="card" id="summary-card">
|
||||||
<div class="muted">Loading summary…</div>
|
<div class="muted">Loading summary…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -23,6 +82,61 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const FLOW_REFRESH_MS = 30000;
|
||||||
|
|
||||||
|
function fmtKw(kw) {
|
||||||
|
if (kw === null || kw === undefined) return '—';
|
||||||
|
return `${kw.toFixed(1)} kW`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(isoTs) {
|
||||||
|
const seconds = Math.max(0, Math.round((Date.now() - new Date(isoTs).getTime()) / 1000));
|
||||||
|
if (seconds < 90) return 'updated just now';
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `updated ${minutes} min ago`;
|
||||||
|
return `updated ${Math.round(minutes / 60)} h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLiveFlow() {
|
||||||
|
const el = document.getElementById('live-updated');
|
||||||
|
const data = await fetch('/api/usage/live').then(r => r.json());
|
||||||
|
if (!data.available) {
|
||||||
|
el.textContent = 'No data yet';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.textContent = fmtAgo(data.ts);
|
||||||
|
|
||||||
|
const generation = data.generation_kw ?? 0;
|
||||||
|
const load = data.load_kw ?? 0;
|
||||||
|
const importKw = data.import_kw ?? 0;
|
||||||
|
const exportKw = data.export_kw ?? 0;
|
||||||
|
const selfConsumed = Math.max(0, generation - exportKw);
|
||||||
|
|
||||||
|
document.getElementById('live-generation').textContent = fmtKw(generation).replace('—', '—');
|
||||||
|
document.getElementById('live-load').textContent = fmtKw(load).replace('—', '—');
|
||||||
|
|
||||||
|
const solarHouseLine = document.getElementById('flow-solar-house');
|
||||||
|
solarHouseLine.style.display = selfConsumed > 0.05 ? '' : 'none';
|
||||||
|
|
||||||
|
const gridLine = document.getElementById('flow-solar-grid');
|
||||||
|
const gridLabel = document.getElementById('live-grid');
|
||||||
|
if (exportKw > 0.05) {
|
||||||
|
gridLine.setAttribute('stroke', 'var(--good)');
|
||||||
|
gridLine.setAttribute('d', 'M 340 70 C 400 130, 420 150, 450 175');
|
||||||
|
gridLine.style.display = '';
|
||||||
|
gridLabel.textContent = `${fmtKw(exportKw).replace('—', '—')} exporting`;
|
||||||
|
} else if (importKw > 0.05) {
|
||||||
|
gridLine.setAttribute('stroke', 'var(--bad)');
|
||||||
|
// Same curve, reversed direction (grid -> solar node's side, feeding the house).
|
||||||
|
gridLine.setAttribute('d', 'M 450 175 C 420 150, 400 130, 340 70');
|
||||||
|
gridLine.style.display = '';
|
||||||
|
gridLabel.textContent = `${fmtKw(importKw).replace('—', '—')} importing`;
|
||||||
|
} else {
|
||||||
|
gridLine.style.display = 'none';
|
||||||
|
gridLabel.textContent = 'no grid flow';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard(days) {
|
async function loadDashboard(days) {
|
||||||
const [summary, daily] = await Promise.all([
|
const [summary, daily] = await Promise.all([
|
||||||
fetch(`/api/usage/summary?days=${days}`).then(r => r.json()),
|
fetch(`/api/usage/summary?days=${days}`).then(r => r.json()),
|
||||||
|
|
@ -33,6 +147,10 @@ async function loadDashboard(days) {
|
||||||
if (!summary.days_with_data) {
|
if (!summary.days_with_data) {
|
||||||
summaryEl.innerHTML = '<div class="muted">No usage data yet. If you\'re running on mock data, it should appear within a few minutes of startup.</div>';
|
summaryEl.innerHTML = '<div class="muted">No usage data yet. If you\'re running on mock data, it should appear within a few minutes of startup.</div>';
|
||||||
} else {
|
} else {
|
||||||
|
const revenue = summary.net_revenue_dollars;
|
||||||
|
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>`;
|
||||||
summaryEl.innerHTML = `
|
summaryEl.innerHTML = `
|
||||||
<div class="muted">${summary.first_day} → ${summary.last_day} (${summary.days_with_data} days with data)</div>
|
<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;">
|
<div style="display:flex; gap:2rem; margin-top:0.75rem; flex-wrap:wrap;">
|
||||||
|
|
@ -40,6 +158,7 @@ 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>
|
||||||
|
${revenueHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -75,5 +194,8 @@ async function loadDashboard(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);
|
||||||
|
|
||||||
|
loadLiveFlow();
|
||||||
|
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue