Include solar export credit in the TOU cost breakdown
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 19s

Previously only showed import cost by tariff period; now also shows the
feed-in/export credit components alongside it, plus a net (usage cost
minus export credit) total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-13 08:16:10 +10:00
parent 1dbfe3007b
commit 918ea39e8d
3 changed files with 52 additions and 15 deletions

View file

@ -125,8 +125,9 @@ def usage_summary(days: str = Query("30")) -> dict:
@router.get("/tou_breakdown") @router.get("/tou_breakdown")
def tou_breakdown(days: str = Query("1")) -> dict: def tou_breakdown(days: str = Query("1")) -> dict:
"""Cost breakdown by the current plan's own usage components (e.g. Peak / """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 Off-Peak / Shoulder) and feed-in components (solar export credit), so
landing rather than just the headline total.""" you can see where your spend -- and your credit -- is actually landing
rather than just the headline total."""
end = datetime.now(timezone.utc) end = datetime.now(timezone.utc)
with get_session() as session: with get_session() as session:
start = _resolve_start(session, days, end) start = _resolve_start(session, days, end)
@ -137,16 +138,28 @@ def tou_breakdown(days: str = Query("1")) -> 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]
result = evaluate(current_plan, readings) result = evaluate(current_plan, readings)
components = [ usage_components = [
{"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)} {"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)}
for c in result.usage_breakdown for c in result.usage_breakdown
if c.kwh > 1e-9 if c.kwh > 1e-9
] ]
# feed_in_breakdown's cost_dollars is already negative (a credit), same
# sign convention the plan comparison table uses.
feed_in_components = [
{"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)}
for c in result.feed_in_breakdown
if c.kwh > 1e-9
]
total_usage_cost = round(sum(c["cost_dollars"] for c in usage_components), 2)
total_feed_in_credit = round(sum(c["cost_dollars"] for c in feed_in_components), 2)
return { return {
"available": True, "available": True,
"days_with_data": result.days_with_data, "days_with_data": result.days_with_data,
"components": sorted(components, key=lambda c: c["cost_dollars"], reverse=True), "usage_components": sorted(usage_components, key=lambda c: c["cost_dollars"], reverse=True),
"total_usage_cost_dollars": round(sum(c["cost_dollars"] for c in components), 2), "feed_in_components": sorted(feed_in_components, key=lambda c: c["cost_dollars"]),
"total_usage_cost_dollars": total_usage_cost,
"total_feed_in_credit_dollars": total_feed_in_credit,
"net_usage_and_export_dollars": round(total_usage_cost + total_feed_in_credit, 2),
} }

View file

@ -202,7 +202,9 @@ form.settings-form label.p-day-label input {
border-radius: 5px; border-radius: 5px;
overflow: hidden; overflow: hidden;
} }
.tou-bar-fill { height: 100%; background: var(--accent); border-radius: 5px; } .tou-bar-fill { height: 100%; border-radius: 5px; }
.tou-bar-fill-cost { background: var(--accent); }
.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; }
.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; }

View file

@ -262,21 +262,43 @@ async function loadDashboard(days) {
}); });
} }
function touBarRows(components, maxAbsCost, fillClass) {
return 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 ${fillClass}" style="width:${Math.max(2, 100 * Math.abs(c.cost_dollars) / maxAbsCost)}%;"></div></div>
<div class="tou-bar-amount">${c.cost_dollars < 0 ? '-' : ''}$${Math.abs(c.cost_dollars).toFixed(2)} &middot; ${c.kwh.toFixed(1)} kWh</div>
</div>
`).join('');
}
async function loadTouBreakdown(days) { async function loadTouBreakdown(days) {
const body = document.getElementById('tou-breakdown-body'); const body = document.getElementById('tou-breakdown-body');
const data = await fetch(`/api/usage/tou_breakdown?days=${days}`).then(r => r.json()); const data = await fetch(`/api/usage/tou_breakdown?days=${days}`).then(r => r.json());
if (!data.available || !data.components.length) { if (!data.available || (!data.usage_components.length && !data.feed_in_components.length)) {
body.innerHTML = '<div class="muted">No usage cost data yet for this period.</div>'; body.innerHTML = '<div class="muted">No usage cost data yet for this period.</div>';
return; return;
} }
const maxCost = Math.max(...data.components.map(c => c.cost_dollars), 0.01); const maxAbsCost = Math.max(
body.innerHTML = data.components.map(c => ` ...data.usage_components.map(c => Math.abs(c.cost_dollars)),
<div class="tou-bar-row"> ...data.feed_in_components.map(c => Math.abs(c.cost_dollars)),
<div class="tou-bar-label">${c.label}</div> 0.01
<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> const usageHtml = data.usage_components.length
`).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>`; ? `<div class="muted" style="margin-top:0.5rem;">Usage cost</div>${touBarRows(data.usage_components, maxAbsCost, 'tou-bar-fill-cost')}`
: '';
const feedInHtml = data.feed_in_components.length
? `<div class="muted" style="margin-top:0.75rem;">Solar export credit</div>${touBarRows(data.feed_in_components, maxAbsCost, 'tou-bar-fill-credit')}`
: '';
const net = data.net_usage_and_export_dollars;
body.innerHTML = usageHtml + feedInHtml + `
<div class="muted" style="margin-top:0.6rem;">
Usage cost $${data.total_usage_cost_dollars.toFixed(2)} &minus; export credit $${Math.abs(data.total_feed_in_credit_dollars).toFixed(2)}
= <strong class="${net >= 0 ? 'savings-negative' : 'savings-positive'}">${net >= 0 ? '$' : '-$'}${Math.abs(net).toFixed(2)}</strong> net
over ${data.days_with_data} day(s) &mdash; excludes supply charge.
</div>`;
} }
const touToggle = document.getElementById('tou-range-toggle'); const touToggle = document.getElementById('tou-range-toggle');