diff --git a/.changeset/stale-meter-no-fake-zero.md b/.changeset/stale-meter-no-fake-zero.md new file mode 100644 index 000000000..5601bf9c0 --- /dev/null +++ b/.changeset/stale-meter-no-fake-zero.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A meter that has stopped reporting no longer shows as 0 W balanced. Grid and house load go blank, and solar or battery that went quiet with it stay on the diagram as no data instead of vanishing. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index ce3011678..fac9600c4 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1141,13 +1141,11 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { "mode": ctrl.Mode, "troubleshooting_mode": troubleshootingMode, "plan_stale": ctrl.PlanStale, - "grid_w": gridW, "pv_w": pvW, "pv_w_predicted": pvPredictW, "bat_w": batW, "ev_w": evW, "v2x_w": v2xW, - "load_w": loadW, "load_w_predicted": loadPredictW, "bat_soc": avgSoC, "grid_target_w": ctrl.GridTargetW, @@ -1177,6 +1175,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { // (under). Idle slots (|planned| ≤ 50 Wh) are ignored. "slot_delivery_stats": ctrl.SlotDeliveryStats, } + // A stale or missing site meter is not 0 W. Publishing zero made the + // dashboard and the FTW app draw "balanced" / "0 W" as if the house + // were idle. JSON null is what the flow mapping already treats as + // "no data". Development setups with no configured meter keep the + // historical zero (haveGrid is forced true above). + if haveGrid { + resp["grid_w"] = gridW + resp["load_w"] = loadW + } else { + resp["grid_w"] = nil + resp["load_w"] = nil + } if energyToday != nil || energyCurrentSlot != nil { energy := map[string]any{} if energyToday != nil { diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index f7e4d6bf7..23645b73d 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -296,6 +296,59 @@ func TestHandleStatusKeepsFaultedSiteMeterReading(t *testing.T) { } } +func TestHandleStatusOmitsWattsWhenSiteMeterIsOffline(t *testing.T) { + tel := telemetry.NewStore() + ctrl := &control.State{SiteMeterDriver: "ferroamp"} + + tel.Update("ferroamp", telemetry.DerMeter, 2400, nil, nil) + soc := 0.55 + tel.Update("ferroamp", telemetry.DerBattery, -500, &soc, nil) + tel.Update("ferroamp", telemetry.DerPV, -1800, nil, nil) + tel.RecordDriverSuccess("ferroamp") + tel.DriverHealthMut("ferroamp").SetOffline() + + srv := New(&Deps{ + Tel: tel, + Ctrl: ctrl, + CtrlMu: &sync.Mutex{}, + CapMu: &sync.RWMutex{}, + Capacities: map[string]float64{"ferroamp": 10_000}, + CfgMu: &sync.RWMutex{}, + Cfg: &config.Config{}, + }) + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", rr.Code, rr.Body.String()) + } + + var raw map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["grid_w"]; !ok { + t.Fatal("grid_w key missing; want explicit JSON null, not an omitted field") + } + if raw["grid_w"] != nil { + t.Fatalf("grid_w = %v, want null so the UI cannot draw 0 W balanced", raw["grid_w"]) + } + if raw["load_w"] != nil { + t.Fatalf("load_w = %v, want null", raw["load_w"]) + } + drivers, _ := raw["drivers"].(map[string]any) + ferroamp, _ := drivers["ferroamp"].(map[string]any) + if ferroamp["status"] != "offline" { + t.Fatalf("driver status = %v, want offline", ferroamp["status"]) + } + if ferroamp["pv_w"] != -1800.0 { + t.Fatalf("pv_w = %v, want last-known -1800 so the UI can keep a solar node", ferroamp["pv_w"]) + } + if ferroamp["bat_w"] != -500.0 { + t.Fatalf("bat_w = %v, want last-known -500 so the UI can keep a battery node", ferroamp["bat_w"]) + } +} + func TestHandleV2XPolicyReturnsLiveEnvelope(t *testing.T) { tel := telemetry.NewStore() tel.Update("meter", telemetry.DerMeter, 1500, nil, nil) diff --git a/web/app.js b/web/app.js index e5caed7f0..d02c1e85b 100644 --- a/web/app.js +++ b/web/app.js @@ -5,21 +5,10 @@ const POLL_INTERVAL = 2000; // status poll cadence — snappier cards - // FLOW_IDLE_KW — magnitude below which a planet is treated as - // "idle / balanced" for label + colour purposes. Mirror of - // ftw-energy-flow.js's FLOW_IDLE_W (which sets window.FTW_FLOW_IDLE_W - // when its module loads). Read at use-time so the module-set value - // wins; literal `42` is the no-modules fallback. Inclusive - // comparison everywhere: |kW| <= threshold ⇒ idle. - function flowIdleKw() { - const w = (typeof window !== "undefined" && window.FTW_FLOW_IDLE_W) || 42; - return w / 1000; - } - function isFlowIdle(kw) { return Math.abs(kw) <= flowIdleKw(); } // Prices arrive as minor units per kWh; what to call them depends on the // configured currency. window.FTWUnits is set when // components/price-units.js loads — same read-at-use-time pattern as - // flowIdleKw above, with öre as the no-modules fallback. + // the energy-flow idle threshold, with öre as the no-modules fallback. function fmtPricePerKwh(minorPerKwh) { const u = typeof window !== "undefined" && window.FTWUnits; if (!u) return minorPerKwh.toFixed(0) + " öre/kWh"; @@ -471,8 +460,9 @@ } if (out.grid_w != null) { out.load_w = Math.max(0, (out.grid_w || 0) - (out.bat_w || 0) - (out.pv_w || 0) - (out.ev_w || 0)); - } else if (data.load_w != null) { - out.load_w = smoothDisplayNumber("site:load_w", data.load_w, now); + } else { + // No live meter: do not keep a previous load or treat JSON null as 0 W. + out.load_w = null; } return out; } @@ -496,17 +486,6 @@ return out; } - // Compact kWh — bubble lines that pack two arrows ("↓ 5.2 ↑ 12") need - // tighter formatting than the standalone tile reading. Drops the "kWh" - // unit (already implied by the bubble label "kWh today" elsewhere). - function fmtKwhShort(kwh) { - if (kwh == null || !isFinite(kwh)) return "—"; - var v = Math.abs(kwh); - if (v >= 100) return kwh.toFixed(0); - if (v >= 10) return kwh.toFixed(1); - return kwh.toFixed(2); - } - function statusClass(status) { if (!status) return "status-offline"; const s = status.toLowerCase(); @@ -582,17 +561,25 @@ if (versionEl && data.version) { versionEl.textContent = data.version; } - // Grid + target indicator - gridW.textContent = formatW(data.grid_w); - if (data.grid_w > 10) { - gridDir.textContent = "importing"; - gridW.className = "card-value val-import"; - } else if (data.grid_w < -10) { - gridDir.textContent = "exporting"; - gridW.className = "card-value val-export"; - } else { - gridDir.textContent = "balanced"; + // Grid + target indicator. A stale meter arrives as JSON null, not 0 — + // 0 W is a real balanced house and must not be used as a missing-data + // stand-in. + if (data.grid_w == null || !isFinite(data.grid_w)) { + gridW.textContent = "—"; + gridDir.textContent = "no data"; gridW.className = "card-value val-neutral"; + } else { + gridW.textContent = formatW(data.grid_w); + if (data.grid_w > 10) { + gridDir.textContent = "importing"; + gridW.className = "card-value val-import"; + } else if (data.grid_w < -10) { + gridDir.textContent = "exporting"; + gridW.className = "card-value val-export"; + } else { + gridDir.textContent = "balanced"; + gridW.className = "card-value val-neutral"; + } } var targetDisp = document.getElementById("grid-target-display"); if (targetDisp) { @@ -621,12 +608,35 @@ // PV — stored as negative (site convention) but displayed positive // so "SOLAR 5.3 kW" reads as generation magnitude without the minus. // Internal data (chart history, hero setReadings, plan math) stays - // on site convention — flip is for this tile only. - pvW.textContent = formatW(-data.pv_w); - pvW.className = "card-value val-generation"; + // on site convention — flip is for this tile only. Configured-but- + // offline solar is "—", not a pretend 0 W of generation. + var pvLive = Object.keys(data.drivers || {}).some(function (name) { + var d = data.drivers[name] || {}; + var online = window.ftwDriverOnline + ? window.ftwDriverOnline(d) + : d.status !== "offline" && d.status !== "disabled" && !d.not_running; + return online && d.pv_w != null; + }); + var pvConfigured = Object.keys(data.drivers || {}).some(function (name) { + return (data.drivers[name] || {}).pv_w != null; + }); + var pvDir = document.getElementById("pv-dir"); + if (pvConfigured && !pvLive) { + pvW.textContent = "—"; + pvW.className = "card-value val-neutral"; + if (pvDir) pvDir.textContent = "no data"; + } else { + pvW.textContent = formatW(-(data.pv_w || 0)); + pvW.className = "card-value val-generation"; + if (pvDir) pvDir.textContent = "generating"; + } // Load - loadW.textContent = formatW(data.load_w || 0); + loadW.textContent = formatW(data.load_w); + var loadDir = document.getElementById("load-dir"); + if (loadDir) { + loadDir.textContent = data.load_w == null || !isFinite(data.load_w) ? "no data" : "using now"; + } // EV tile (tile-mode parity with the energy-flow's EV planet). // Reads ev_charging_w (post sub-watt floor in /api/status); the @@ -641,22 +651,39 @@ if (cardEvSubEl) cardEvSubEl.textContent = evWNow > 1 ? "charging" : "charger"; } - // Battery — positive=charge, negative=discharge - batW.textContent = formatW(data.bat_w); - if (data.bat_w > 10) { - batDir.textContent = "charging"; - batW.className = "card-value val-charging"; - } else if (data.bat_w < -10) { - batDir.textContent = "discharging"; - batW.className = "card-value val-discharging"; - } else { - batDir.textContent = "idle"; + // Battery — positive=charge, negative=discharge. Same honesty as solar: + // a configured pack that is not reporting is "—", not idle at 0 W. + var batLive = Object.keys(data.drivers || {}).some(function (name) { + var d = data.drivers[name] || {}; + var online = window.ftwDriverOnline + ? window.ftwDriverOnline(d) + : d.status !== "offline" && d.status !== "disabled" && !d.not_running; + return online && d.bat_w != null; + }); + var batConfigured = Object.keys(data.drivers || {}).some(function (name) { + return (data.drivers[name] || {}).bat_w != null; + }); + if (batConfigured && !batLive) { + batW.textContent = "—"; + batDir.textContent = "no data"; batW.className = "card-value val-neutral"; + } else { + batW.textContent = formatW(data.bat_w); + if (data.bat_w > 10) { + batDir.textContent = "charging"; + batW.className = "card-value val-charging"; + } else if (data.bat_w < -10) { + batDir.textContent = "discharging"; + batW.className = "card-value val-discharging"; + } else { + batDir.textContent = "idle"; + batW.className = "card-value val-neutral"; + } } if (batSoc) { - batSoc.textContent = Number.isFinite(data.bat_soc) - ? Math.round(data.bat_soc * 100) + "% SoC" - : "—"; + batSoc.textContent = (batConfigured && !batLive) || !Number.isFinite(data.bat_soc) + ? "—" + : Math.round(data.bat_soc * 100) + "% SoC"; } var batTargetDisp = document.getElementById("bat-target-display"); if (batTargetDisp) { @@ -674,175 +701,35 @@ updateLiveStat("bat", data.bat_w, signClass("bat", data.bat_w)); updateLiveSocStat(data.bat_soc); - // Hero energy-flow diagram — build a flat "planets" list where each - // entry declares which corner it orbits (top-left=PV, top-right= - // battery, bottom-left=grid, bottom-right=EV). The component knows - // nothing about driver roles — all role→color/sub-text/direction - // mapping lives here so the four corners stay a caller concern. - // setReadings() replaces `planets` atomically, so a transient - // /api/status error preserves the last good layout if we skip it. + // Hero energy-flow diagram. Mapping lives in energy-flow-readings.js + // so a stale meter cannot be drawn as 0 W "balanced" and a quiet + // hybrid inverter cannot vanish from the X. EV SoC is overlayed here + // because it comes from the loadpoint table, not /api/status. var flowEl = document.getElementById("energy-flow"); - if (flowEl) { - var planets = []; - - // Today's totals are aggregate across all drivers; per-driver kWh split - // is not in the API. Mark them as aggregate-only so the energy-flow - // component can show them on folded bubbles without duplicating the - // same total on every individual inverter. - var todayE = (data.energy && data.energy.today) || {}; - var importKwh = (todayE.import_wh || 0) / 1000; - var exportKwh = (todayE.export_wh || 0) / 1000; - var pvKwhTotal = (todayE.pv_wh || 0) / 1000; - var loadKwhTotal = (todayE.load_wh || 0) / 1000; - var batChargedKwh = (todayE.bat_charged_wh || 0) / 1000; - var batDischargedKwh = (todayE.bat_discharged_wh || 0) / 1000; - // Solar only flows one direction (production); the arrow would - // be redundant. Use the kWh unit instead so the line reads as - // a standalone total. - var pvDailyStr = fmtKwhShort(pvKwhTotal) + " kWh"; - // Grid daily totals are colour-coded: import red, export green, - // both bold so the polarity reads at a glance against the dark - // bubble. Other planets stay on the plain dimmed text style. - var gridDailyParts = [ - { text: "↓ " + fmtKwhShort(importKwh), color: "var(--red-e)", bold: true }, - { text: "↑ " + fmtKwhShort(exportKwh), color: "var(--green-e)", bold: true }, - ]; - // Battery daily totals share the grid's colour discipline: - // charge (energy stored) green, discharge (energy spent) red. - // Reads at a glance whether the day was a net-fill or net-drain. - var batDailyParts = [ - { text: "↑ " + fmtKwhShort(batChargedKwh), color: "var(--green-e)", bold: true }, - { text: "↓ " + fmtKwhShort(batDischargedKwh), color: "var(--red-e)", bold: true }, - ]; - - // Grid — single utility, bottom-left corner. Import = toward house. - var gkw = (data.grid_w || 0) / 1000; - var gIdle = isFlowIdle(gkw); - planets.push({ - id: "grid", corner: "bottom-left", title: "GRID", role: "grid", - kw: gkw, toHub: gkw >= 0, - color: gIdle ? "var(--fg-muted)" : - (gkw >= 0 ? "var(--red-e)" : "var(--green-e)"), - sub: gIdle ? "balanced" : - (gkw >= 0 ? "importing" : "exporting"), - dailyKwhParts: gridDailyParts, - }); - - var drvs = data.drivers || {}; - var pvDailyMembers = 0; - var batDailyMembers = 0; - Object.keys(drvs).forEach(function (name) { - var d = drvs[name] || {}; - if (d.pv_w != null) pvDailyMembers++; - if (d.bat_w != null) batDailyMembers++; + if (flowEl && typeof window.ftwFlowReadingsFromStatus === "function") { + lastFlowReadings = window.ftwFlowReadingsFromStatus(data, { + idleW: (typeof window.FTW_FLOW_IDLE_W === "number" && window.FTW_FLOW_IDLE_W) || 42, + batterySub: function (name, d) { + if (d.observe_only) return "observe only"; + return batteryTargetLine(batteryTargetsByDriver[name]); + }, }); - Object.keys(drvs).forEach(function (name) { - var d = drvs[name] || {}; - var online = d.status !== "offline" && d.status !== "disabled" && !d.not_running; - if (!online) return; - // Solar — display positive kW when generating (site convention - // has pv_w negative for export into the house). All internal - // state (chart history, math) stays on site convention; the - // sign flip is display-only and lives in this function. - if (d.pv_w != null) { - var pvKw = -d.pv_w / 1000; - var pvGen = !isFlowIdle(pvKw); - planets.push({ - id: "pv-" + name, corner: "top-left", title: "SOLAR", name: name, role: "pv", - kw: pvKw, toHub: true, - color: pvGen ? "var(--amber)" : "var(--fg-muted)", - // Solar is one-directional: the power value alone already - // shows whether it's generating or idle. The sub-label - // would just repeat the same fact in words. - sub: "", - dailyKwh: pvDailyStr, - dailyScope: "aggregate", - dailyAggregateMembers: pvDailyMembers, - }); + (lastFlowReadings.planets || []).forEach(function (p) { + if (p.role !== "ev" || p.placeholder || !p.name) return; + var lpEv = loadpointsByDriver && loadpointsByDriver[p.name]; + if (!lpEv) return; + if (lpEv.vehicle_soc > 0) { + p.soc = lpEv.vehicle_soc * 100; + p.socSource = "vehicle"; + } else if (lpEv.current_soc > 0) { + p.soc = lpEv.current_soc * 100; + p.socSource = lpEv.soc_source || "inferred"; } - // Battery — sign shows charge/discharge. Discharge flows toward - // the house; charge flows away from it. - if (d.bat_w != null) { - var bKw = d.bat_w / 1000; - var bIdle = isFlowIdle(bKw); - // Direction conveyed by colour of the power value: charge - // green (filling), discharge red (draining), idle stays - // neutral cyan (the battery's identity hue). Drops the - // wordy charging/discharging sub-label. - var bColor = bIdle ? "var(--cyan)" : - (bKw >= 0 ? "var(--green-e)" : "var(--red-e)"); - var bTargetLine = d.observe_only - ? "observe only" - : batteryTargetLine(batteryTargetsByDriver[name]); - planets.push({ - id: "bat-" + name, corner: "top-right", title: "BATTERY", name: name, role: "battery", - kw: bKw, toHub: bKw < 0, - color: bColor, - sub: bTargetLine, - soc: d.bat_soc != null ? Math.round(d.bat_soc * 100) : null, - dailyKwhParts: batDailyParts, - dailyScope: "aggregate", - dailyAggregateMembers: batDailyMembers, - clickable: !d.observe_only, - }); - } - // EV — always consumes from the house side. When a loadpoint - // maps to this driver AND a vehicle telemetry source is - // reporting (DerVehicle), inject the vehicle's own SoC + - // charge-limit so the bubble renders "24 / 50 %" — measured - // truth instead of session-Wh estimate. - if (d.ev_w != null) { - var eKw = d.ev_w / 1000; - var eActive = !isFlowIdle(eKw); - var lpEv = loadpointsByDriver && loadpointsByDriver[name]; - var evSoc = null; - var evLimit = null; - var evSocStale = false; - var evSocSource = null; - if (lpEv) { - // Prefer vehicle-reported when present; fall back to - // the inferred SoC the manager computed from session_wh. - if (lpEv.vehicle_soc > 0) { - evSoc = lpEv.vehicle_soc * 100; - evSocSource = "vehicle"; - } else if (lpEv.current_soc > 0) { - evSoc = lpEv.current_soc * 100; - evSocSource = lpEv.soc_source || "inferred"; - } - if (lpEv.vehicle_charge_limit > 0) { - evLimit = lpEv.vehicle_charge_limit * 100; - } - evSocStale = !!lpEv.vehicle_stale; - } - planets.push({ - id: "ev-" + name, corner: "bottom-right", title: "EV CHARGER", name: name, role: "ev", - kw: eKw, toHub: false, - color: eActive ? "var(--green-e)" : "var(--white-s)", - sub: eActive ? "charging" : "idle", - soc: evSoc, - chargeLimit: evLimit, - socStale: evSocStale, - socSource: evSocSource, - }); + if (lpEv.vehicle_charge_limit > 0) { + p.chargeLimit = lpEv.vehicle_charge_limit * 100; } + p.socStale = !!lpEv.vehicle_stale; }); - - // Self-powered today: share of recorded house consumption sourced - // from PV/battery over the whole day. Daily EV energy is not split - // into this aggregate yet, while the realtime component includes - // active EV load because it is visible in the live balance. - // Clamped 0..100 because metering glitches can briefly report - // import > load. - var selfPoweredPctToday = null; - if (loadKwhTotal > 0.001) { - selfPoweredPctToday = Math.max(0, Math.min(100, - (1 - importKwh / loadKwhTotal) * 100)); - } - lastFlowReadings = { - load: (data.load_w || 0) / 1000, - planets: planets, - selfPoweredPctToday: selfPoweredPctToday, - }; if (typeof flowEl.setReadings === "function") { flowEl.setReadings(lastFlowReadings); } else if (!flowUpgradeReplayQueued && @@ -1097,7 +984,7 @@ chartHistory.grid.push(data.grid_w); chartHistory.pv.push(data.pv_w); - chartHistory.load.push(data.load_w || 0); + chartHistory.load.push(data.load_w); chartHistory.timestamps.push(now); chartHistory.e_import.push(t.import_wh || 0); chartHistory.e_export.push(t.export_wh || 0); diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js new file mode 100644 index 000000000..509c5cb73 --- /dev/null +++ b/web/components/energy-flow-readings.js @@ -0,0 +1,228 @@ +// Status → readings. +// +// The dashboard (app.js) feeds the hero through this file. A missing +// grid_w is "no data", never 0 W "balanced". Hardware that exists but +// is offline stays on the diagram as a placeholder — hiding it makes a +// hybrid inverter look like the house never had solar or a battery. +// A spare offline inverter next to a live one of the same role stays +// hidden; that is an extra dead device, not the whole category gone. + +// Mirror of ftw-energy-flow.js's FLOW_IDLE_W. Read at use-time so the +// component's window.FTW_FLOW_IDLE_W wins once that module has loaded; +// 42 is the no-modules / unit-test fallback. Do not import the +// component from here — that would pull Custom Elements into Node tests. +function idleW() { + return (typeof window !== "undefined" && window.FTW_FLOW_IDLE_W) || 42; +} + +export function driverOnline(d) { + const status = typeof d?.status === "string" ? d.status : ""; + return status !== "offline" && status !== "disabled" && d?.not_running !== true; +} + +export function num(v) { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +function isIdle(w, thresholdW) { + return Math.abs(w) <= thresholdW; +} + +export function fmtKwhShort(kwh) { + if (kwh == null || !Number.isFinite(kwh)) return "—"; + const v = Math.abs(kwh); + if (v >= 100) return kwh.toFixed(0); + if (v >= 10) return kwh.toFixed(1); + return kwh.toFixed(2); +} + +function planetColor(role, watts, thresholdW) { + if (watts === undefined || watts === null) { + if (role === "grid" || role === "pv") return "var(--fg-muted)"; + if (role === "battery") return "var(--cyan)"; + if (role === "ev") return "var(--white-s)"; + return "var(--fg)"; + } + if (role === "grid") { + return isIdle(watts, thresholdW) ? "var(--fg-muted)" : watts >= 0 ? "var(--red-e)" : "var(--green-e)"; + } + if (role === "pv") return isIdle(watts, thresholdW) ? "var(--fg-muted)" : "var(--amber)"; + if (role === "battery") { + return isIdle(watts, thresholdW) ? "var(--cyan)" : watts >= 0 ? "var(--green-e)" : "var(--red-e)"; + } + if (role === "ev") return isIdle(watts, thresholdW) ? "var(--white-s)" : "var(--green-e)"; + return "var(--fg)"; +} + +function placeholderPlanet(partial) { + return { + kw: 0, + toHub: true, + color: "var(--fg-muted)", + sub: "no data", + clickable: false, + placeholder: true, + ...partial, + }; +} + +/** + * @param {object} status GET /api/status body + * @param {object} [opts] + * @param {number} [opts.idleW] + * @param {(name: string, driver: object, batW: number) => string} [opts.batterySub] + */ +export function flowReadingsFromStatus(status, opts) { + const thresholdW = (opts && opts.idleW) || idleW(); + 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 gridDailyParts = [ + { text: `↓ ${fmtKwhShort(importKwh)}`, color: "var(--red-e)", bold: true }, + { text: `↑ ${fmtKwhShort(exportKwh)}`, 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 }, + ]; + + const gridW = num(status && status.grid_w); + if (gridW === null) { + planets.push(placeholderPlanet({ + id: "grid", corner: "bottom-left", title: "GRID", role: "grid", + })); + } else { + const gIdle = isIdle(gridW, thresholdW); + planets.push({ + id: "grid", corner: "bottom-left", title: "GRID", role: "grid", + kw: Math.abs(gridW) / 1000, toHub: gridW >= 0, + color: planetColor("grid", gridW, thresholdW), + sub: gIdle ? "balanced" : gridW >= 0 ? "importing" : "exporting", + dailyKwhParts: gridDailyParts, + clickable: true, + }); + } + + const drivers = (status && status.drivers) || {}; + const names = Object.keys(drivers); + let pvDailyMembers = 0; + let batDailyMembers = 0; + for (const name of names) { + const d = drivers[name]; + if (!d) continue; + if (d.pv_w != null) pvDailyMembers++; + if (d.bat_w != null) batDailyMembers++; + } + + const live = { pv: false, battery: false, ev: false }; + const offline = { pv: [], battery: [], ev: [] }; + + for (const name of names) { + const d = drivers[name]; + if (!d) continue; + const online = driverOnline(d); + + const pvW = num(d.pv_w); + if (pvW !== null) { + const planet = { + id: `pv-${name}`, corner: "top-left", title: "SOLAR", role: "pv", name, + kw: -pvW / 1000, toHub: true, + color: planetColor("pv", pvW, thresholdW), + sub: "", + dailyKwh: pvDailyStr, + dailyScope: "aggregate", + dailyAggregateMembers: pvDailyMembers, + clickable: true, + }; + if (online) { + live.pv = true; + planets.push(planet); + } else { + offline.pv.push(placeholderPlanet({ + id: planet.id, corner: planet.corner, title: planet.title, + role: planet.role, name, + })); + } + } + + const batW = num(d.bat_w); + if (batW !== null) { + const bIdle = isIdle(batW, thresholdW); + const soc = num(d.bat_soc); + const defaultSub = d.observe_only === true + ? "observe only" + : bIdle ? "idle" : batW >= 0 ? "charging" : "discharging"; + const sub = batterySub ? batterySub(name, d, batW) : defaultSub; + const planet = { + id: `bat-${name}`, corner: "top-right", title: "BATTERY", role: "battery", name, + kw: batW / 1000, toHub: batW < 0, + color: planetColor("battery", batW, thresholdW), + sub, + soc: soc === null ? null : Math.round(soc * 100), + dailyKwhParts: batDailyParts, + dailyScope: "aggregate", + dailyAggregateMembers: batDailyMembers, + clickable: d.observe_only !== true, + }; + if (online) { + live.battery = true; + planets.push(planet); + } else { + offline.battery.push(placeholderPlanet({ + id: planet.id, corner: planet.corner, title: planet.title, + role: planet.role, name, + })); + } + } + + const evW = num(d.ev_w); + if (evW !== null) { + const active = !isIdle(evW, thresholdW); + const planet = { + id: `ev-${name}`, corner: "bottom-right", title: "EV CHARGER", role: "ev", name, + kw: Math.abs(evW) / 1000, toHub: false, + color: planetColor("ev", evW, thresholdW), + sub: active ? "charging" : "idle", + clickable: true, + }; + if (online) { + live.ev = true; + planets.push(planet); + } else { + offline.ev.push(placeholderPlanet({ + id: planet.id, corner: planet.corner, title: planet.title, + role: planet.role, name, + })); + } + } + } + + for (const role of ["pv", "battery", "ev"]) { + if (!live[role]) planets.push(...offline[role]); + } + + const loadW = num(status && status.load_w); + let selfPoweredPctToday = null; + if (loadKwhTotal > 0.001 && loadW !== null) { + selfPoweredPctToday = Math.max(0, Math.min(100, (1 - importKwh / loadKwhTotal) * 100)); + } + + return { + load: loadW === null ? null : loadW / 1000, + planets, + selfPoweredPctToday, + }; +} + +if (typeof window !== "undefined") { + window.ftwFlowReadingsFromStatus = flowReadingsFromStatus; + window.ftwDriverOnline = driverOnline; +} diff --git a/web/components/ftw-energy-flow.js b/web/components/ftw-energy-flow.js index ea75fa56b..3337f5e6f 100644 --- a/web/components/ftw-energy-flow.js +++ b/web/components/ftw-energy-flow.js @@ -59,11 +59,10 @@ import { FtwElement, ftwDebugDelay } from "./ftw-element.js"; // idle/balanced" threshold (in watts, magnitude). Used by: // - this component (beam activation, sub-label "idle / charging / // generating", aggregated-bubble greyscale, self-powered %) -// - web/app.js per-planet object construction (mirrors via -// window.FTW_FLOW_IDLE_W set below — non-module script, can't -// import; falls back to the same literal if this module hasn't -// loaded yet) -// +// - energy-flow-readings.js (and the phone app's copy of that mapping) +// via window.FTW_FLOW_IDLE_W. Classic app.js cannot import; it falls +// back to the same literal if this module has not loaded yet. + // Inclusive comparison everywhere: |kW| <= threshold ⇒ idle, strictly // > threshold ⇒ active. So at exactly 42 W the planet is idle AND the // beam is inactive — no mixed state at the boundary. @@ -561,7 +560,10 @@ class FtwEnergyFlow extends FtwElement { // `planets` leaves the previous cluster intact (useful during // transient /api/status errors so the diagram doesn't blank out). setReadings(r) { - if (r.load != null) this._readings.load = r.load; + // `in` so an explicit null (stale meter, unknown house load) replaces + // a previous number. `!= null` would keep drawing the last 0 W as if + // the house were idle. + if ("load" in r) this._readings.load = r.load; if (Array.isArray(r.planets)) this._readings.planets = r.planets; // Optional today's-totals payload pushed through to the central // hub render. selfPoweredPctToday is the share of consumption @@ -834,21 +836,24 @@ class FtwEnergyFlow extends FtwElement { render() { const { load } = this._readings; + const loadKnown = load != null && Number.isFinite(Number(load)); // Self-powered % for the visible site demand — house load plus any // active EV charger. When EV is excluded, a 9 kW car charge can make a // PV+battery-covered house display 0 % simply because grid import exceeds // the house-only load. The energy-flow diagram shows the EV as part of the // live balance, so the denominator should match what is on screen. + // Unknown load (stale meter) is not 0 % — that would claim the house + // is fully self-powered while we cannot see it. let selfPoweredPct = null; - { + if (loadKnown) { let gridImport = 0; for (const p of (this._readings.planets || [])) { - if (p.role === "grid" && p.toHub) gridImport += Math.max(0, p.kw || 0); + if (p.role === "grid" && !p.placeholder && p.toHub) gridImport += Math.max(0, p.kw || 0); } let evDemandKw = 0; for (const p of (this._readings.planets || [])) { - if (p.role === "ev") evDemandKw += Math.max(0, p.kw || 0); + if (p.role === "ev" && !p.placeholder) evDemandKw += Math.max(0, p.kw || 0); } const consumptionKw = (Math.abs(load) || 0) + evDemandKw; if (!isIdleKw(consumptionKw)) { @@ -1150,7 +1155,7 @@ class FtwEnergyFlow extends FtwElement { can open the house's own live reading — the click handler reads data-role and fires ftw-planet-click with role "load". --> + tabindex="0" role="button" aria-label="${loadKnown ? "House load, live" : "House load, no data"}"> @@ -1166,7 +1171,7 @@ class FtwEnergyFlow extends FtwElement { - ${fmtKw(load)} + ${loadKnown ? fmtKw(load) : "—"} ${selfPoweredPct !== null ? ` { ); }); + it("builds the hero from the shared status mapper, not inline 0 W defaults", () => { + assert.match(app, /ftwFlowReadingsFromStatus/); + assert.doesNotMatch(app, /var gkw = \(data\.grid_w \|\| 0\) \/ 1000/); + }); + it("keeps each live telemetry rendering target singular", () => { for (const id of [ "grid-w", diff --git a/web/energy-flow-readings.test.mjs b/web/energy-flow-readings.test.mjs new file mode 100644 index 000000000..d03e68733 --- /dev/null +++ b/web/energy-flow-readings.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { flowReadingsFromStatus, fmtKwhShort } from "./components/energy-flow-readings.js"; + +const LIVE = { + grid_w: 500, + load_w: 970, + energy: { + today: { + import_wh: 5200, + export_wh: 12_400, + pv_wh: 18_100, + load_wh: 14_000, + bat_charged_wh: 4100, + bat_discharged_wh: 2800, + }, + }, + drivers: { + east: { status: "ok", pv_w: -1800 }, + west: { status: "ok", pv_w: -500 }, + lynx: { status: "ok", bat_w: 1800, bat_soc: 0.687 }, + easee: { status: "ok", ev_w: 7200 }, + dead: { status: "offline", pv_w: -900 }, + }, +}; + +describe("flowReadingsFromStatus", () => { + it("draws one planet per live driver and hides a spare offline inverter", () => { + const r = flowReadingsFromStatus(LIVE); + assert.deepEqual( + r.planets.map((p) => p.id).sort(), + ["bat-lynx", "ev-easee", "grid", "pv-east", "pv-west"], + ); + assert.equal( + r.planets.some((p) => p.id === "pv-dead"), + false, + "an extra offline inverter became a planet next to live solar", + ); + assert.equal(r.load, 0.97); + }); + + it("keeps a faulted charger on the diagram", () => { + const r = flowReadingsFromStatus({ + grid_w: 0, + load_w: 200, + drivers: { easee: { status: "fault", ev_w: 11_400 } }, + }); + assert.equal(r.planets.find((p) => p.id === "ev-easee")?.kw, 11.4); + }); + + it("still says balanced for a real 0 W meter reading", () => { + const r = flowReadingsFromStatus({ + grid_w: 0, + load_w: 200, + drivers: { easee: { status: "ok", ev_w: 0 } }, + }); + const grid = r.planets.find((p) => p.id === "grid"); + assert.equal(grid.sub, "balanced"); + assert.equal(grid.placeholder, undefined); + assert.equal(r.load, 0.2); + }); + + it("says no data instead of 0 W balanced when the meter is missing", () => { + const r = flowReadingsFromStatus({ + grid_w: null, + load_w: null, + drivers: { easee: { status: "ok", ev_w: 0 } }, + }); + const grid = r.planets.find((p) => p.id === "grid"); + assert.equal(grid.sub, "no data"); + assert.equal(grid.placeholder, true); + assert.equal(grid.clickable, false); + assert.equal(r.load, null); + }); + + it("keeps solar and battery on the diagram when the only inverter goes quiet", () => { + // The phone screenshot: a hybrid that is the meter, the PV and the + // battery goes offline. Skipping it left GRID + EV at 0 W and looked + // like the house never had solar. + const r = flowReadingsFromStatus({ + grid_w: null, + load_w: null, + drivers: { + ferroamp: { status: "offline", pv_w: -3400, bat_w: 900, bat_soc: 0.62 }, + easee: { status: "ok", ev_w: 0 }, + }, + }); + const ids = r.planets.map((p) => p.id).sort(); + assert.deepEqual(ids, ["bat-ferroamp", "ev-easee", "grid", "pv-ferroamp"]); + const solar = r.planets.find((p) => p.id === "pv-ferroamp"); + const battery = r.planets.find((p) => p.id === "bat-ferroamp"); + const grid = r.planets.find((p) => p.id === "grid"); + assert.equal(solar.placeholder, true); + assert.equal(solar.sub, "no data"); + assert.equal(battery.placeholder, true); + assert.equal(grid.placeholder, true); + const ev = r.planets.find((p) => p.id === "ev-easee"); + assert.equal(ev.placeholder, undefined); + assert.equal(ev.sub, "idle"); + }); + + it("writes today onto live bubbles", () => { + const r = flowReadingsFromStatus(LIVE); + const grid = r.planets.find((p) => p.id === "grid"); + assert.deepEqual(grid.dailyKwhParts?.map((p) => p.text), ["↓ 5.20", "↑ 12.4"]); + const solar = r.planets.find((p) => p.id === "pv-east"); + assert.equal(solar.dailyKwh, "18.1 kWh"); + assert.ok(Math.abs(r.selfPoweredPctToday - (1 - 5.2 / 14) * 100) < 1e-6); + }); + + it("keeps battery sign so two discharging packs do not look like charging", () => { + const r = flowReadingsFromStatus({ + grid_w: 0, + load_w: 3500, + drivers: { + a: { status: "ok", bat_w: -2000, bat_soc: 0.4 }, + b: { status: "ok", bat_w: -1500, bat_soc: 0.5 }, + }, + }); + const bats = r.planets.filter((p) => p.role === "battery"); + assert.ok(Math.abs(bats.reduce((sum, p) => sum + p.kw, 0) + 3.5) < 1e-9); + assert.ok(bats.every((p) => p.sub === "discharging")); + }); +}); + +describe("fmtKwhShort", () => { + it("matches the dashboard bubble rounding", () => { + assert.equal(fmtKwhShort(5.2), "5.20"); + assert.equal(fmtKwhShort(12.4), "12.4"); + assert.equal(fmtKwhShort(100.6), "101"); + }); +}); diff --git a/web/index.html b/web/index.html index 2aa33e5fe..ca36f1d4e 100644 --- a/web/index.html +++ b/web/index.html @@ -184,7 +184,7 @@

Power now

Solar
-
generating
+
generating
Grid
@@ -194,7 +194,7 @@

Power now

Home
-
using now
+
using now
Battery