Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/energy-today-measured-gaps.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 1 addition & 8 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions go/internal/api/api_energy_today.go
Original file line number Diff line number Diff line change
@@ -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
}
95 changes: 95 additions & 0 deletions go/internal/api/api_energy_today_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
3 changes: 3 additions & 0 deletions go/internal/state/history_dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions go/internal/state/history_hot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion go/internal/state/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 10 additions & 14 deletions web/components/energy-flow-readings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions web/energy-flow-readings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions web/energy-today.js
Original file line number Diff line number Diff line change
@@ -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.";
}
37 changes: 37 additions & 0 deletions web/energy-today.test.mjs
Original file line number Diff line number Diff line change
@@ -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.");
});
Loading