From e003ff4526d0c58dd691e6a67c5e003c661061f3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 14:24:54 +0200 Subject: [PATCH] fix: show recorded energy when today's readings have gaps Signed-off-by: Fredrik Ahlgren --- .changeset/energy-today-measured-gaps.md | 5 ++ go/internal/api/api.go | 9 +-- go/internal/api/api_energy_today.go | 28 +++++++ go/internal/api/api_energy_today_test.go | 95 ++++++++++++++++++++++++ go/internal/state/history_dashboard.go | 3 + go/internal/state/history_hot.go | 1 + go/internal/state/store.go | 23 +++++- web/app.js | 4 + web/components/energy-flow-readings.js | 24 +++--- web/energy-flow-readings.test.mjs | 17 +++++ web/energy-today.js | 56 ++++++++++++++ web/energy-today.test.mjs | 37 +++++++++ 12 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 .changeset/energy-today-measured-gaps.md create mode 100644 go/internal/api/api_energy_today.go create mode 100644 go/internal/api/api_energy_today_test.go create mode 100644 web/energy-today.js create mode 100644 web/energy-today.test.mjs diff --git a/.changeset/energy-today-measured-gaps.md b/.changeset/energy-today-measured-gaps.md new file mode 100644 index 000000000..1744698dd --- /dev/null +++ b/.changeset/energy-today-measured-gaps.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show recorded energy after gaps in today's solar or consumption readings, with a clear mark for partial totals. Keep missing totals unknown in the flow view instead of showing zero. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 8a30689d3..a802fe9ab 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1146,14 +1146,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { // 0; rendering that as a hard "0 Wh" looks like a real reading // instead of "no data yet". Mirror the old `len(pts) > 1` guard. if d.Intervals > 0 { - energyToday = map[string]any{ - "import_wh": d.Missing.Value(state.MissingGrid, d.ImportWh), - "export_wh": d.Missing.Value(state.MissingGrid, d.ExportWh), - "pv_wh": d.Missing.Value(state.MissingPV, d.PVWh), - "bat_charged_wh": d.Missing.Value(state.MissingBattery, d.BatChargedWh), - "bat_discharged_wh": d.Missing.Value(state.MissingBattery, d.BatDischargedWh), - "load_wh": d.Missing.Value(state.MissingLoad, d.LoadWh), - } + energyToday = energyTodayFields(d) } } else { slog.Warn("failed to integrate today's energy", "err", err) diff --git a/go/internal/api/api_energy_today.go b/go/internal/api/api_energy_today.go new file mode 100644 index 000000000..57ea58739 --- /dev/null +++ b/go/internal/api/api_energy_today.go @@ -0,0 +1,28 @@ +package api + +import "github.com/srcfl/ftw/go/internal/state" + +func energyTodayFields(d state.DayEnergy) map[string]any { + if d.Intervals == 0 { + return nil + } + out := map[string]any{"history_quality": d.Missing.Quality()} + measured := make(map[string]any) + for key, field := range map[string]struct { + flag state.HistoryMissing + wh float64 + }{ + "import_wh": {state.MissingGrid, d.ImportWh}, + "export_wh": {state.MissingGrid, d.ExportWh}, + "pv_wh": {state.MissingPV, d.PVWh}, + "bat_charged_wh": {state.MissingBattery, d.BatChargedWh}, + "bat_discharged_wh": {state.MissingBattery, d.BatDischargedWh}, + "load_wh": {state.MissingLoad, d.LoadWh}, + } { + // Existing clients still receive null for an incomplete total. + out[key] = d.Missing.Value(field.flag, field.wh) + measured[key] = d.MeasuredValue(field.flag, field.wh) + } + out["measured"] = measured + return out +} diff --git a/go/internal/api/api_energy_today_test.go b/go/internal/api/api_energy_today_test.go new file mode 100644 index 000000000..a73fd405c --- /dev/null +++ b/go/internal/api/api_energy_today_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "context" + "encoding/json" + "math" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEnergyTodayExposesMeasuredEnergyAfterPVPollGap(t *testing.T) { + for _, tc := range []struct { + name string + pv float64 + allMissing bool + }{ + {"producing after recovery", -600, false}, + {"measured zero after recovery", 0, false}, + {"never measured", 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.EnableHistoryAggregation(); err != nil { + t.Fatal(err) + } + now := time.Now() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + if now.Sub(midnight) < 4*time.Minute { + t.Skip("need four elapsed minutes in the current day") + } + for i := 0; i < 4; i++ { + p := state.HistoryPoint{TsMs: midnight.Add(time.Duration(i) * time.Minute).UnixMilli(), GridW: 1200, PVW: tc.pv, BatW: 200, LoadW: 1000 - tc.pv, JSON: `{}`} + if tc.allMissing || i == 1 { + p.Missing = state.MissingPV | state.MissingLoad + p.ClearMissingValues() + } + if err := st.EnqueueTelemetryTick(&p, nil, nil); err != nil { + t.Fatal(err) + } + } + if err := st.FlushHistory(context.Background()); err != nil { + t.Fatal(err) + } + srv := New(&Deps{State: st, Tel: telemetry.NewStore(), Ctrl: &control.State{}, CtrlMu: &sync.Mutex{}, CapMu: &sync.RWMutex{}, CfgMu: &sync.RWMutex{}, Cfg: &config.Config{}}) + rr := httptest.NewRecorder() + srv.handleStatus(rr, httptest.NewRequest("GET", "/api/status", nil)) + var resp struct { + Energy struct { + Today map[string]any `json:"today"` + } `json:"energy"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + today := resp.Energy.Today + if today["pv_wh"] != nil || today["load_wh"] != nil { + t.Fatal("partial energy claimed as a complete day", today) + } + if today["history_quality"] != "incomplete" { + t.Fatal("missing day quality", today) + } + measured, ok := today["measured"].(map[string]any) + if !ok { + t.Fatal("known energy hidden after a brief PV gap", today) + } + if tc.allMissing { + if measured["pv_wh"] != nil || measured["load_wh"] != nil { + t.Fatal("unmeasured flow became a measured zero", measured) + } + } else { + for key, want := range map[string]float64{"pv_wh": -tc.pv / 60, "load_wh": (1000 - tc.pv) / 60} { + got, ok := measured[key].(float64) + if !ok || math.Abs(got-want) > 1e-9 { + t.Fatalf("%s measured=%v, want %v without filling the gap", key, measured[key], want) + } + } + } + if math.Abs(today["import_wh"].(float64)-60) > 1e-9 { + t.Fatal("independent meter energy changed", today) + } + }) + } +} diff --git a/go/internal/state/history_dashboard.go b/go/internal/state/history_dashboard.go index 0c995d2c7..6c7f20c41 100644 --- a/go/internal/state/history_dashboard.go +++ b/go/internal/state/history_dashboard.go @@ -226,6 +226,9 @@ func (s *Store) aggregateDayEnergy(ctx context.Context, from, to int64) (DayEner out.LoadWh += r.load out.Intervals += r.n out.Missing |= r.missing + if r.covered > 0 { + out.observed |= MissingPower &^ r.missing + } return nil }) return out, err diff --git a/go/internal/state/history_hot.go b/go/internal/state/history_hot.go index a4692dcb3..5498b7653 100644 --- a/go/internal/state/history_hot.go +++ b/go/internal/state/history_hot.go @@ -56,6 +56,7 @@ func (s *Store) dailyEnergyFromHot(ctx context.Context, sinceMs, untilMs int64) } func addDayEnergy(a, b DayEnergy) DayEnergy { + a.observed = a.observedFlows() | b.observedFlows() a.ImportWh += b.ImportWh a.ExportWh += b.ExportWh a.PVWh += b.PVWh diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 6f5619e4e..2ff6de9ed 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -1566,7 +1566,11 @@ func (s *Store) loadArchiveHistory(ctx context.Context, sinceMs, untilMs int64, // Import/Export are grid-boundary W split on sign; PV and LoadWh are always // positive accumulations; BatCharged/BatDischarged split bat_w on sign. type DayEnergy struct { - Missing HistoryMissing + Missing HistoryMissing + // observed records flows with a known integration interval, including + // measured zero. It is derived from history rows, not persisted in the + // closed-day cache, whose totals retain their full-range quality mask. + observed HistoryMissing ImportWh float64 ExportWh float64 PVWh float64 @@ -1582,6 +1586,23 @@ type DayEnergy struct { Intervals int64 } +// MeasuredValue returns the recorded subtotal without claiming a complete +// range. Positive energy proves a contribution even when a minute bucket +// combines known and missing intervals; zero needs a known interval. +func (d DayEnergy) MeasuredValue(flag HistoryMissing, value float64) any { + if d.Intervals > 0 && (d.Missing&flag == 0 || d.observed&flag != 0 || value > 0) { + return value + } + return nil +} + +func (d DayEnergy) observedFlows() HistoryMissing { + if d.Intervals > 0 { + return d.observed | (MissingPower &^ d.Missing) + } + return d.observed +} + // DailyEnergy integrates history W columns over [sinceMs, untilMs] and returns // Wh totals in a single round-trip. The integration uses the value at the // LATER sample of each interval over that interval's width diff --git a/web/app.js b/web/app.js index 40ae1a193..589250457 100644 --- a/web/app.js +++ b/web/app.js @@ -7,6 +7,7 @@ const evPlanUI = import("/ev-plan.js").catch(function () { return null; }); const POLL_INTERVAL = 2000; // status poll cadence — snappier cards const historyMigrationUI = import("/history-migration.js").catch(function () { return null; }); + const energyTodayUI = import("/energy-today.js").catch(function () { return null; }); function updateHistoryMigration(health) { historyMigrationUI.then(function (ui) { if (ui) ui.updateMigrationBanner(health); }).catch(function () {}); if (updateBadge && typeof updateBadge.setBootHealth === "function") updateBadge.setBootHealth(health); @@ -887,6 +888,9 @@ if (peakLimitSend) peakLimitSend.disabled = true; // pristine } // Energy today + energyTodayUI.then(function (ui) { + if (ui) ui.updateEnergyToday(data.energy && data.energy.today); + }); if (data.energy && data.energy.today) { var t = data.energy.today; if (eImport) eImport.textContent = formatKwh(t.import_wh); diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index 7b6e705e2..443e1012d 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -12,6 +12,7 @@ // 42 is the no-modules / unit-test fallback. Do not import the // component from here — that would pull Custom Elements into Node tests. import {withControlMarks} from "../control-feedback.js"; +import {formatEnergyToday} from "../energy-today.js"; function idleW() { return (typeof window !== "undefined" && window.FTW_FLOW_IDLE_W) || 42; @@ -79,21 +80,16 @@ export function flowReadingsFromStatus(status, opts) { const batterySub = opts && opts.batterySub; const planets = []; const today = (status && status.energy && status.energy.today) || {}; - const importKwh = (num(today.import_wh) ?? 0) / 1000; - const exportKwh = (num(today.export_wh) ?? 0) / 1000; - const pvKwhTotal = (num(today.pv_wh) ?? 0) / 1000; - const loadKwhTotal = (num(today.load_wh) ?? 0) / 1000; - const batChargedKwh = (num(today.bat_charged_wh) ?? 0) / 1000; - const batDischargedKwh = (num(today.bat_discharged_wh) ?? 0) / 1000; - - const pvDailyStr = `${fmtKwhShort(pvKwhTotal)} kWh`; + const importWh = num(today.import_wh); + const loadWh = num(today.load_wh); + const pvDailyStr = formatEnergyToday(today, "pv_wh"); const gridDailyParts = [ - { text: `↓ ${fmtKwhShort(importKwh)}`, color: "var(--red-e)", bold: true }, - { text: `↑ ${fmtKwhShort(exportKwh)}`, color: "var(--green-e)", bold: true }, + { text: `↓ ${formatEnergyToday(today, "import_wh", false)}`, color: "var(--red-e)", bold: true }, + { text: `↑ ${formatEnergyToday(today, "export_wh", false)}`, color: "var(--green-e)", bold: true }, ]; const batDailyParts = [ - { text: `↑ ${fmtKwhShort(batChargedKwh)}`, color: "var(--green-e)", bold: true }, - { text: `↓ ${fmtKwhShort(batDischargedKwh)}`, color: "var(--red-e)", bold: true }, + { text: `↑ ${formatEnergyToday(today, "bat_charged_wh", false)}`, color: "var(--green-e)", bold: true }, + { text: `↓ ${formatEnergyToday(today, "bat_discharged_wh", false)}`, color: "var(--red-e)", bold: true }, ]; const gridW = num(status && status.grid_w); @@ -216,8 +212,8 @@ export function flowReadingsFromStatus(status, opts) { // quiet meter blanks % SELF-POWERED NOW (unknown instantaneous load) // but must not hide a day that already happened. let selfPoweredPctToday = null; - if (loadKwhTotal > 0.001) { - selfPoweredPctToday = Math.max(0, Math.min(100, (1 - importKwh / loadKwhTotal) * 100)); + if (loadWh > 1 && importWh !== null) { + selfPoweredPctToday = Math.max(0, Math.min(100, (1 - importWh / loadWh) * 100)); } return { diff --git a/web/energy-flow-readings.test.mjs b/web/energy-flow-readings.test.mjs index c91ca55cf..af5fe1146 100644 --- a/web/energy-flow-readings.test.mjs +++ b/web/energy-flow-readings.test.mjs @@ -26,6 +26,23 @@ const LIVE = { }; describe("flowReadingsFromStatus", () => { + it("keeps a missing solar day total unknown instead of inventing zero", () => { + const status = structuredClone(LIVE); + status.energy.today.pv_wh = null; + const solar = flowReadingsFromStatus(status).planets.find(p => p.role === "pv"); + assert.equal(solar.dailyKwh, "Unknown"); + }); + + it("marks measured day energy after a gap without inferring a daily share", () => { + const status = structuredClone(LIVE); + status.energy.today.pv_wh = null; + status.energy.today.load_wh = null; + status.energy.today.measured = {pv_wh: 600, load_wh: 1200}; + const result = flowReadingsFromStatus(status); + assert.equal(result.planets.find(p => p.role === "pv").dailyKwh, "≥ 0.60 kWh"); + assert.equal(result.selfPoweredPctToday, null); + }); + it("draws one planet per live driver and hides a spare offline inverter", () => { const r = flowReadingsFromStatus(LIVE); assert.deepEqual( diff --git a/web/energy-today.js b/web/energy-today.js new file mode 100644 index 000000000..36e10d511 --- /dev/null +++ b/web/energy-today.js @@ -0,0 +1,56 @@ +const ENERGY_FIELDS = { + "e-import": "import_wh", + "e-export": "export_wh", + "e-pv": "pv_wh", + "e-charged": "bat_charged_wh", + "e-discharged": "bat_discharged_wh", + "e-load": "load_wh", + "overview-e-import": "import_wh", + "overview-e-export": "export_wh", + "overview-e-pv": "pv_wh", +}; + +function knownWh(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function energyTodayReading(today, key) { + const total = today?.[key]; + if (knownWh(total)) return {wh: total, partial: false}; + const measured = today?.measured?.[key]; + if (knownWh(measured)) return {wh: measured, partial: true}; + return {wh: null, partial: false}; +} + +export function formatEnergyToday(today, key, withUnit = true) { + const {wh, partial} = energyTodayReading(today, key); + if (wh === null) return "Unknown"; + const kwh = wh / 1000; + const digits = kwh >= 100 ? 0 : kwh >= 10 ? 1 : 2; + return `${partial ? "≥ " : ""}${kwh.toFixed(digits)}${withUnit ? " kWh" : ""}`; +} + +export function updateEnergyToday(today, root = document) { + for (const [id, key] of Object.entries(ENERGY_FIELDS)) { + const el = root.getElementById(id); + if (!el) continue; + const {partial} = energyTodayReading(today, key); + el.textContent = formatEnergyToday(today, key); + el.title = partial ? "Recorded energy only; missing readings are excluded." : ""; + } + const group = root.getElementById("e-pv")?.closest(".energy-group"); + if (!group) return; + let note = root.getElementById("energy-today-quality"); + if (!note) { + note = root.createElement("p"); + note.id = "energy-today-quality"; + note.className = "card-sub"; + group.append(note); + } + const keys = new Set(Object.values(ENERGY_FIELDS)); + const incomplete = [...keys].some(key => !knownWh(today?.[key])); + note.hidden = !incomplete; + note.textContent = today + ? "Gaps in today's readings. ≥ shows recorded energy only." + : "Today's energy is not available yet."; +} diff --git a/web/energy-today.test.mjs b/web/energy-today.test.mjs new file mode 100644 index 000000000..382d9bd53 --- /dev/null +++ b/web/energy-today.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {formatEnergyToday, updateEnergyToday} from "./energy-today.js"; + +test("today distinguishes a complete zero, a measured subtotal and no evidence", () => { + assert.equal(formatEnergyToday({pv_wh: 0}, "pv_wh"), "0.00 kWh"); + assert.equal(formatEnergyToday({pv_wh: null, measured: {pv_wh: 0}}, "pv_wh"), "≥ 0.00 kWh"); + assert.equal(formatEnergyToday({pv_wh: null, measured: {pv_wh: 600}}, "pv_wh"), "≥ 0.60 kWh"); + for (const invalid of [undefined, null, NaN, Infinity, -10, "600"]) { + assert.equal(formatEnergyToday({pv_wh: null, measured: {pv_wh: invalid}}, "pv_wh"), "Unknown"); + } + assert.equal(formatEnergyToday({pv_wh: 1200, measured: {pv_wh: 600}}, "pv_wh"), "1.20 kWh"); +}); + +test("the energy cards and overview mark subtotals and clear the mark after a complete reading", () => { + const elements = new Map(); + const group = {append(el) { elements.set(el.id, el); }}; + for (const id of ["e-pv", "e-load", "overview-e-pv"]) { + elements.set(id, {closest() { return group; }}); + } + const root = {getElementById(id) { return elements.get(id); }, createElement() { return {}; }}; + updateEnergyToday({pv_wh: null, load_wh: null, measured: {pv_wh: 600, load_wh: 1200}}, root); + assert.equal(elements.get("e-pv").textContent, "≥ 0.60 kWh"); + assert.equal(elements.get("overview-e-pv").textContent, "≥ 0.60 kWh"); + assert.equal(elements.get("e-load").textContent, "≥ 1.20 kWh"); + assert.match(elements.get("e-pv").title, /missing readings/); + assert.equal(elements.get("energy-today-quality").hidden, false); + + const complete = {pv_wh: 1000, load_wh: 2000, import_wh: 500, export_wh: 0, bat_charged_wh: 0, bat_discharged_wh: 0}; + updateEnergyToday(complete, root); + assert.equal(elements.get("e-pv").textContent, "1.00 kWh"); + assert.equal(elements.get("e-pv").title, ""); + assert.equal(elements.get("energy-today-quality").hidden, true); + updateEnergyToday(null, root); + assert.equal(elements.get("e-pv").textContent, "Unknown"); + assert.equal(elements.get("energy-today-quality").textContent, "Today's energy is not available yet."); +});