Skip to content

Commit ad1dde6

Browse files
authored
Merge pull request #36 from basecubedev/bugfix/Add-since_date-to-Energy-Lifetime-Statistics
Add to Energy Lifetime Statistics
2 parents a2645e0 + 643d833 commit ad1dde6

4 files changed

Lines changed: 115 additions & 11 deletions

File tree

dashboard/sqlite_store.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def record(self, snapshot):
196196

197197
def latest(self):
198198
if self._latest is not None:
199+
self._latest["energy_stats"] = self.energy_summary()
199200
return self._latest
200201

201202
with self._lock, self._connect() as con:
@@ -209,6 +210,7 @@ def latest(self):
209210
return snapshot
210211

211212
self._latest = json.loads(row[0])
213+
self._latest["energy_stats"] = self.energy_summary()
212214
return self._latest
213215

214216
def history(self, range_name="6h"):
@@ -383,7 +385,10 @@ def _energy_summary(self, con, now=None):
383385
for month in range(1, 13)
384386
],
385387
"yearly": [],
386-
"lifetime": _energy_payload(0, 0),
388+
"lifetime": {
389+
**_energy_payload(0, 0),
390+
"since_date": None,
391+
},
387392
})
388393

389394
return summary
@@ -472,12 +477,16 @@ def _lifetime_summary(self, con):
472477
"""
473478
SELECT
474479
COALESCE(SUM(inverter_output_wh), 0),
475-
COALESCE(SUM(savings_value), 0)
480+
COALESCE(SUM(savings_value), 0),
481+
MIN(date)
476482
FROM daily_energy_stats
483+
WHERE sample_count > 0
477484
"""
478485
).fetchone()
479486

480-
return _energy_payload(row[0], row[1])
487+
payload = _energy_payload(row[0], row[1])
488+
payload["since_date"] = row[2]
489+
return payload
481490

482491
def _cleanup(self, con):
483492
cutoff = (

dashboard/static/app.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ function renderEnergyStats(stats) {
317317
const monthly = normalizeMonthlyEnergy(stats.monthly_current_year);
318318
const yearly = normalizeYearlyEnergy(stats.yearly);
319319
const lifetime = stats.lifetime || {};
320-
const hasEnergy = [
320+
const hasCollectedStats = Boolean(lifetime.since_date) || [
321321
stats.today,
322322
stats.last_7_days,
323323
stats.last_4_weeks,
@@ -328,7 +328,7 @@ function renderEnergyStats(stats) {
328328
...yearly,
329329
].some((item) => energyKwh(item) > 0);
330330

331-
if (!hasEnergy) {
331+
if (!hasCollectedStats) {
332332
container.innerHTML = `<div class="energy-empty control-empty compact">Waiting for the first measured inverter output sample.</div>`;
333333
return;
334334
}
@@ -443,10 +443,18 @@ function energyLifetimeCard(values, currency) {
443443
values,
444444
currency,
445445
className: "energy-lifetime-card",
446+
details: values?.since_date
447+
? [{ label: "Date", value: formatEnergyDate(values.since_date) }]
448+
: [],
446449
});
447450
}
448451

449-
function energySummaryCard({ title, subtitle, values, currency, className = "", current = false }) {
452+
function energySummaryCard({ title, subtitle, values, currency, className = "", current = false, details = [] }) {
453+
const detailFacts = details
454+
.filter((detail) => detail?.value)
455+
.map((detail) => energyFact(detail.label || "Detail", detail.value, detail.iconName || "history", detail.tone || "neutral"))
456+
.join("");
457+
450458
return `
451459
<article class="energy-summary-card ${escapeHtml(className)} ${current ? "energy-current" : ""}">
452460
<div class="energy-summary-head">
@@ -456,6 +464,7 @@ function energySummaryCard({ title, subtitle, values, currency, className = "",
456464
<div class="energy-summary-values">
457465
${energyFact("Energy", formatEnergyKwh(values), "inverter", "output")}
458466
${energyFact("Savings", formatSavings(values, currency), "charge", "savings")}
467+
${detailFacts}
459468
</div>
460469
</article>
461470
`;
@@ -567,8 +576,11 @@ function formatSavings(values, currency) {
567576

568577
function formatEnergyDate(value) {
569578
if (!value) return "";
579+
const raw = String(value);
580+
const dateOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
581+
if (dateOnly) return `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}`;
570582
const date = new Date(value);
571-
if (Number.isNaN(date.getTime())) return String(value);
583+
if (Number.isNaN(date.getTime())) return raw;
572584
return date.toISOString().slice(0, 10);
573585
}
574586

tests/test_dashboard_battery_flow_semantics.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ class FakeElement {
378378
{ year: 2026, inverter_output_kwh: 840.0, savings_value: 294.0 },
379379
{ year: 2027, inverter_output_kwh: 910.0, savings_value: 318.5 },
380380
],
381-
lifetime: { inverter_output_kwh: 2070.0, savings_value: 724.5 },
381+
lifetime: { inverter_output_kwh: 2070.0, savings_value: 724.5, since_date: "2026-05-31" },
382382
};
383383
384384
const html = render(stats);
@@ -405,6 +405,8 @@ class FakeElement {
405405
assert(html.includes("Monthly Summary"), "monthly section renders");
406406
assert(html.includes("Yearly Summary"), "yearly section renders");
407407
assert(html.includes("Result / Lifetime"), "Lifetime card renders as a normal result summary");
408+
assert(html.includes("All stored daily totals"), "Lifetime subtitle remains descriptive");
409+
assert(html.includes("Date") && html.includes("2026-05-31"), "Lifetime since date renders as a detail field");
408410
assert(html.includes("3.2 kWh"), "Today kWh renders");
409411
assert(html.includes("18.4 kWh"), "Last 7 Days kWh renders");
410412
assert(html.includes("72.1 kWh"), "Last 4 Weeks kWh renders");
@@ -433,6 +435,7 @@ class FakeElement {
433435
assert(render(undefined).includes("Energy statistics not available yet."), "missing stats fallback renders");
434436
assert(render({ enabled: false }).includes("Energy statistics are disabled."), "disabled fallback renders");
435437
assert(render({ enabled: true, currency: "EUR", today: { inverter_output_kwh: 0 }, lifetime: { inverter_output_kwh: 0 } }).includes("Waiting for the first measured inverter output sample."), "first sample fallback renders");
438+
assert(render({ enabled: true, currency: "EUR", today: { inverter_output_kwh: 0 }, lifetime: { inverter_output_kwh: 0, since_date: "2026-05-31" } }).includes("2026-05-31"), "lifetime since date renders before energy accumulates");
436439
437440
const demo = context.demoSnapshot();
438441
context.renderSnapshot(demo);

tests/test_dashboard_store.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import sqlite3
23
from datetime import datetime, timedelta, timezone
34
from types import SimpleNamespace
@@ -65,7 +66,7 @@ def daily_row(path, date_key):
6566
).fetchone()
6667

6768

68-
def insert_daily(path, date_key, wh, savings=0, peak=0):
69+
def insert_daily(path, date_key, wh, savings=0, peak=0, sample_count=1):
6970
with sqlite3.connect(path) as con:
7071
con.execute(
7172
"""
@@ -79,9 +80,16 @@ def insert_daily(path, date_key, wh, savings=0, peak=0):
7980
sample_count,
8081
updated_at
8182
)
82-
VALUES(?, ?, ?, 0.35, 'EUR', ?, 1, ?)
83+
VALUES(?, ?, ?, 0.35, 'EUR', ?, ?, ?)
8384
""",
84-
(date_key, wh, savings, peak, f"{date_key}T12:00:00+00:00"),
85+
(
86+
date_key,
87+
wh,
88+
savings,
89+
peak,
90+
sample_count,
91+
f"{date_key}T12:00:00+00:00",
92+
),
8593
)
8694

8795

@@ -96,6 +104,35 @@ def test_store_records_latest_and_history(tmp_path):
96104
assert store.latest()["control_explain"] is None
97105

98106

107+
def test_latest_refreshes_energy_stats_from_daily_aggregates(tmp_path):
108+
path = tmp_path / "dashboard.sqlite"
109+
DashboardStore(path)
110+
insert_daily(path, "2026-05-31", 1000, savings=1)
111+
timestamp = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc).isoformat()
112+
113+
with sqlite3.connect(path) as con:
114+
stale_snapshot = snapshot(timestamp)
115+
stale_snapshot["energy_stats"] = {
116+
"enabled": True,
117+
"currency": "EUR",
118+
"lifetime": {
119+
"inverter_output_wh": 0,
120+
"inverter_output_kwh": 0,
121+
"savings_value": 0,
122+
},
123+
}
124+
con.execute(
125+
"INSERT INTO snapshots(timestamp, payload) VALUES(?, ?)",
126+
(timestamp, json.dumps(stale_snapshot)),
127+
)
128+
129+
store = DashboardStore(path)
130+
latest = store.latest()
131+
132+
assert latest["energy_stats"]["lifetime"]["inverter_output_wh"] == 1000
133+
assert latest["energy_stats"]["lifetime"]["since_date"] == "2026-05-31"
134+
135+
99136
def test_store_cleanup_uses_retention(tmp_path):
100137
store = DashboardStore(tmp_path / "dashboard.sqlite", retention_hours=1)
101138
old = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
@@ -287,10 +324,53 @@ def test_energy_rolling_summaries_and_best_day(tmp_path):
287324
assert summary["last_4_weeks"]["inverter_output_wh"] == 10000
288325
assert summary["last_12_months"]["inverter_output_wh"] == 15000
289326
assert summary["lifetime"]["inverter_output_wh"] == 21000
327+
assert summary["lifetime"]["since_date"] == "2025-06-29"
290328
assert summary["best_day"]["date"] == "2025-06-29"
291329
assert summary["best_day"]["inverter_output_wh"] == 6000
292330

293331

332+
def test_energy_lifetime_since_date_is_null_without_daily_stats(tmp_path):
333+
store = DashboardStore(tmp_path / "dashboard.sqlite")
334+
335+
summary = store.energy_summary(
336+
now=datetime(2026, 6, 29, 12, 0, tzinfo=timezone.utc)
337+
)
338+
339+
assert summary["lifetime"]["inverter_output_wh"] == 0
340+
assert summary["lifetime"]["since_date"] is None
341+
342+
343+
def test_energy_lifetime_since_date_uses_first_collected_day_across_gaps(tmp_path):
344+
path = tmp_path / "dashboard.sqlite"
345+
DashboardStore(path)
346+
insert_daily(path, "2026-06-01", 1000, savings=1)
347+
insert_daily(path, "2026-06-08", 2000, savings=2)
348+
349+
store = DashboardStore(path)
350+
summary = store.energy_summary(
351+
now=datetime(2026, 6, 29, 12, 0, tzinfo=timezone.utc)
352+
)
353+
354+
assert summary["lifetime"]["inverter_output_wh"] == 3000
355+
assert summary["lifetime"]["since_date"] == "2026-06-01"
356+
357+
358+
def test_energy_lifetime_since_date_ignores_zero_sample_days(tmp_path):
359+
path = tmp_path / "dashboard.sqlite"
360+
DashboardStore(path)
361+
insert_daily(path, "2026-05-31", 5000, savings=5, sample_count=0)
362+
insert_daily(path, "2026-06-01", 1000, savings=1)
363+
insert_daily(path, "2026-06-02", 2000, savings=2)
364+
365+
store = DashboardStore(path)
366+
summary = store.energy_summary(
367+
now=datetime(2026, 6, 29, 12, 0, tzinfo=timezone.utc)
368+
)
369+
370+
assert summary["lifetime"]["inverter_output_wh"] == 3000
371+
assert summary["lifetime"]["since_date"] == "2026-06-01"
372+
373+
294374
def test_energy_monthly_and_yearly_summaries(tmp_path):
295375
path = tmp_path / "dashboard.sqlite"
296376
DashboardStore(path)

0 commit comments

Comments
 (0)