From c4f495b8530d9b1fc48c40522f8b7c79006ea88a Mon Sep 17 00:00:00 2001 From: "frahlg-agents[bot]" Date: Wed, 21 Oct 2026 06:51:25 +0200 Subject: [PATCH 1/4] fix(live): Home use from the site meter, quality separate from history --- .changeset/live-home-use-quality.md | 5 + go/cmd/ftw/app_link.go | 2 +- go/cmd/ftw/history_dark_pv.go | 13 ++ go/cmd/ftw/live_load_dark_test.go | 37 ++++++ go/cmd/ftw/live_load_test.go | 14 ++- go/cmd/ftw/main.go | 7 +- go/internal/api/api_history_quality.go | 16 ++- go/internal/api/api_history_quality_test.go | 50 +++++++- go/internal/api/api_home_estimate_test.go | 5 +- go/internal/api/api_support_live_load_test.go | 27 +++- go/internal/api/api_support_report.go | 31 +++-- go/internal/api/api_test.go | 4 +- go/internal/ha/bridge.go | 4 +- go/internal/ha/live_load_test.go | 10 +- go/internal/telemetry/forecast.go | 107 +++++++++++++++- .../telemetry/live_load_quality_test.go | 117 ++++++++++++++++++ web/app.js | 3 +- web/components/energy-flow-readings.js | 3 +- web/energy-flow-readings.test.mjs | 8 ++ 19 files changed, 431 insertions(+), 32 deletions(-) create mode 100644 .changeset/live-home-use-quality.md create mode 100644 go/cmd/ftw/live_load_dark_test.go create mode 100644 go/internal/telemetry/live_load_quality_test.go diff --git a/.changeset/live-home-use-quality.md b/.changeset/live-home-use-quality.md new file mode 100644 index 000000000..134962f9b --- /dev/null +++ b/.changeset/live-home-use-quality.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show Home use again when one device is offline. Live Home use now comes from the site meter and every device that is read, as before 0.141. A battery, charger or daytime inverter that is offline is left out and Home use is marked estimated; the dashboard shows "estimated" and "≈" instead of a blank. After sunset at the site's map pin an offline inverter counts as 0 W. Home use is unknown only when the site meter is missing or stale, or the readings do not balance. `/api/status` adds `load_quality` (measured, estimated, unknown) and `load_reason`. History and forecast quality are unchanged. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 7a346bf3e..9dc51ee99 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -81,7 +81,7 @@ func (a *appSite) Snapshot() appproto.Snapshot { live := a.tel.LiveMeasurementNow(siteMeterDriver, opts) snap := appproto.Snapshot{Mode: mode, ControlRev: rev, FuseA: fuseA, GridW: live.GridW, PVW: live.PVW, BatteryW: live.BatteryW, EVW: live.EVW, - LoadW: live.HouseholdW, LoadWKnown: live.Valid} + LoadW: live.LoadW, LoadWKnown: live.LoadKnown} uptimeMs := time.Since(a.started).Milliseconds() seen := map[string]bool{} diff --git a/go/cmd/ftw/history_dark_pv.go b/go/cmd/ftw/history_dark_pv.go index 40e0e82e0..4d4de6864 100644 --- a/go/cmd/ftw/history_dark_pv.go +++ b/go/cmd/ftw/history_dark_pv.go @@ -36,6 +36,19 @@ func (s historySun) zenith(atMS int64) (float64, bool) { return sunpos.At(time.UnixMilli(atMS), s.Lat, s.Lon).ZenithDeg, true } +// darkness tells live Home use how dark it is at the site, by the same +// sunset and civil-dusk limits as history. +func (s historySun) darkness(at time.Time) telemetry.Darkness { + z, placed := s.zenith(at.UnixMilli()) + switch { + case !placed || z <= sunsetZenithDeg: + return telemetry.DarknessUnknown + case z <= civilDuskZenithDeg: + return telemetry.DarknessTwilight + } + return telemetry.DarknessNight +} + // darkPV records an unknown PV source as 0 W while the sun is down at the // site: physics, not interpolation. Past civil dusk the 0 W is as good as a // reading and carries no marker, so a night with a sleeping inverter also diff --git a/go/cmd/ftw/live_load_dark_test.go b/go/cmd/ftw/live_load_dark_test.go new file mode 100644 index 000000000..363f5043d --- /dev/null +++ b/go/cmd/ftw/live_load_dark_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestHistorySunDarknessForLiveLoad(t *testing.T) { + sun := historySunOf(59.33, 18.07, true) // Stockholm + cases := map[string]telemetry.Darkness{ + "2026-06-21T12:00:00+02:00": telemetry.DarknessUnknown, + "2026-10-21T02:00:00+02:00": telemetry.DarknessNight, + } + for at, want := range cases { + ts, _ := time.Parse(time.RFC3339, at) + if got := sun.darkness(ts); got != want { + t.Fatalf("%s: darkness %d, want %d", at, got, want) + } + } + if (historySun{}).darkness(time.Now()) != telemetry.DarknessUnknown { + t.Fatal("an unplaced site inferred darkness") + } + // Through an October evening: daylight, then twilight, then night, in order. + start, _ := time.Parse(time.RFC3339, "2026-10-21T15:00:00+02:00") + seen := []telemetry.Darkness{} + for m := 0; m <= 300; m++ { + d := sun.darkness(start.Add(time.Duration(m) * time.Minute)) + if len(seen) == 0 || seen[len(seen)-1] != d { + seen = append(seen, d) + } + } + if len(seen) != 3 || seen[0] != telemetry.DarknessUnknown || seen[1] != telemetry.DarknessTwilight || seen[2] != telemetry.DarknessNight { + t.Fatalf("evening sequence %v", seen) + } +} diff --git a/go/cmd/ftw/live_load_test.go b/go/cmd/ftw/live_load_test.go index f7535cb6b..1771e8076 100644 --- a/go/cmd/ftw/live_load_test.go +++ b/go/cmd/ftw/live_load_test.go @@ -80,7 +80,8 @@ func TestLiveLoadAppAndLANShareSourcePowerAndValidity(t *testing.T) { case "offline_ev": tel.Update("charger", telemetry.DerEV, 500, nil, nil) tel.DriverHealthMut("charger").SetOffline() - valid = false + // Left out, as before #1592: Home use is an estimate, not blank. + want = 1000 case "idle_ev", "paused_ev": fields := map[string]any{"connected": scenario == "paused_ev", "charging": false, "control_power_observed_at": time.Now().Add(-time.Hour).Format(time.RFC3339Nano), @@ -136,11 +137,16 @@ func TestLiveLoadOptionsFollowConfigurationForAppLANAndCloud(t *testing.T) { snap := site.Snapshot() status := liveLoadStatus(t, tel, ctrl, options) _, cloudKnown := (&cloudSite{site: site}).Live().Fields[strconv.Itoa(int(appproto.FidLoadW))] - known := !batteryDeclared - if snap.LoadWKnown != known || (status["load_w"] != nil) != known || cloudKnown != known { + // A declared battery that never reported is left out: Home use stays + // known on every client and is marked estimated on the LAN. + if !snap.LoadWKnown || status["load_w"] == nil || !cloudKnown { t.Fatal("configured never-emitted flow differs between clients") } - if known && (snap.LoadW != 1000 || status["load_w"] != float64(1000)) { + wantQuality := map[bool]string{true: "estimated", false: "measured"}[batteryDeclared] + if status["load_quality"] != wantQuality { + t.Fatalf("load_quality = %v, want %s", status["load_quality"], wantQuality) + } + if snap.LoadW != 1000 || status["load_w"] != float64(1000) { t.Fatal("learning-only identity gate affected live load") } if batteryDeclared { diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index e62a67454..594b52459 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -858,7 +858,10 @@ func main() { } forecastSettings := newForecastSiteConfig(st) liveMeasurementOptions := func() telemetry.ForecastOptions { - return forecastSettings.Snapshot().HistoryOptions + site := forecastSettings.Snapshot() + opts := site.HistoryOptions + opts.PVDark = site.HistorySun.darkness + return opts } forecastSettings.identity = func(name string) (string, bool) { if id, ok := runningDeviceID(reg, name); ok { @@ -3169,6 +3172,8 @@ func main() { "ev_w", evW, "v2x_w", v2xW, "load_valid", live.Valid, + "load_quality", live.LoadQuality, + "load_reason", live.LoadReason, "self_tune_active", selfTuneActive, "self_tune_driver", selfTuneName, "self_tune_command_w", selfTuneCmd, diff --git a/go/internal/api/api_history_quality.go b/go/internal/api/api_history_quality.go index a53a7c21c..24d7a6110 100644 --- a/go/internal/api/api_history_quality.go +++ b/go/internal/api/api_history_quality.go @@ -37,14 +37,26 @@ func (s *Server) qualifyStatusHistory(resp map[string]any, siteMeter string) { missing |= state.BalanceMismatch } resp["grid_w"] = missing.Value(state.MissingGrid, r.GridW) - resp["pv_w"] = missing.Value(state.MissingPV, r.PVW) + // Live power is not history: a dark inverter is 0 W and Home use stays + // known while the site meter is read (load_quality says how). + if r.PVLiveKnown && opts.PVInvalidReason == "" { + resp["pv_w"] = r.PVW + } else { + resp["pv_w"] = nil + } resp["bat_w"] = missing.Value(state.MissingBattery, r.BatteryW) resp["ev_w"] = missing.Value(state.MissingEV, r.EVW) if missing&state.MissingEV != 0 { resp["ev_charging_w"] = nil } resp["v2x_w"] = missing.Value(state.MissingV2X, r.V2XW) - resp["load_w"] = missing.Value(state.MissingLoad, r.HouseholdW) + if r.LoadKnown { + resp["load_w"] = r.LoadW + } else { + resp["load_w"] = nil + } + resp["load_quality"] = r.LoadQuality + resp["load_reason"] = r.LoadReason resp["history_quality"] = missing.Quality() resp["history_reason"] = r.Reason drivers, _ := resp["drivers"].(map[string]any) diff --git a/go/internal/api/api_history_quality_test.go b/go/internal/api/api_history_quality_test.go index 92bfd4e49..77c6b1c70 100644 --- a/go/internal/api/api_history_quality_test.go +++ b/go/internal/api/api_history_quality_test.go @@ -77,7 +77,8 @@ func TestLiveHistoryQualitySeparatesOfflineFromInvalidBalance(t *testing.T) { }}) resp := map[string]any{} srv.qualifyStatusHistory(resp, "meter") - if resp["grid_w"] != float64(1200) || resp["bat_w"] != nil || resp["load_w"] != nil || resp["history_quality"] != "incomplete" { + // Live Home use leaves the offline battery out; history stays incomplete. + if resp["grid_w"] != float64(1200) || resp["bat_w"] != nil || resp["load_w"] != float64(1200) || resp["load_quality"] != "estimated" || resp["history_quality"] != "incomplete" { t.Fatal(resp) } tel.Update("battery", telemetry.DerBattery, 2000, nil, nil) @@ -130,7 +131,50 @@ func TestLiveHistoryQualityConfiguredPVWithoutMeasurementIsUnknown(t *testing.T) }}) resp := map[string]any{} srv.qualifyStatusHistory(resp, "meter") - if resp["pv_w"] != nil || resp["load_w"] != nil { - t.Fatal("unmeasured configured PV reported as zero", resp) + if resp["pv_w"] != nil || resp["load_w"] != float64(1200) || resp["load_quality"] != "estimated" || resp["history_quality"] != "incomplete" { + t.Fatal("unmeasured configured PV reported as zero, or Home use blanked", resp) + } +} + +// Anders (Discord, v0.154.1): Growatt offline after sunset, Pixii meter and +// battery read. /api/status showed pv_w and load_w null. +func TestStatusAndersNightAndDay(t *testing.T) { + for _, tc := range []struct { + name string + dark telemetry.Darkness + pv any + quality string + loadWant any + }{ + {"night", telemetry.DarknessNight, float64(0), "measured", float64(980)}, + {"day", telemetry.DarknessUnknown, nil, "estimated", float64(980)}, + } { + t.Run(tc.name, func(t *testing.T) { + tel := telemetry.NewStore() + for _, name := range []string{"pixii", "growatt"} { + tel.EnsureDriverHealth(name) + } + tel.Update("pixii", telemetry.DerMeter, 780, nil, nil) + tel.Update("pixii", telemetry.DerBattery, -200, nil, nil) + tel.RecordDriverSuccess("pixii") + tel.Update("growatt", telemetry.DerPV, -40, nil, nil) + tel.DriverHealthMut("growatt").SetOffline() + dark := tc.dark + srv := New(&Deps{Tel: tel, HistoryMeasurementSources: func() telemetry.ForecastOptions { + return telemetry.ForecastOptions{PVDark: func(time.Time) telemetry.Darkness { return dark }} + }}) + resp := map[string]any{"drivers": map[string]any{"growatt": map[string]any{}}} + srv.qualifyStatusHistory(resp, "pixii") + if resp["grid_w"] != float64(780) || resp["pv_w"] != tc.pv || resp["load_w"] != tc.loadWant || resp["load_quality"] != tc.quality { + t.Fatal(resp) + } + // History quality stays separate from live Home use. + if resp["history_quality"] != "incomplete" || resp["history_reason"] != "offline:growatt:pv" { + t.Fatal("live display changed history quality", resp) + } + if tc.dark == telemetry.DarknessNight && resp["drivers"].(map[string]any)["growatt"].(map[string]any)["pv_w"] == nil { + t.Fatal("dark inverter not shown as 0 W") + } + }) } } diff --git a/go/internal/api/api_home_estimate_test.go b/go/internal/api/api_home_estimate_test.go index 02ad03018..b040d124c 100644 --- a/go/internal/api/api_home_estimate_test.go +++ b/go/internal/api/api_home_estimate_test.go @@ -65,8 +65,9 @@ func TestStatusKeepsPowerBalanceWhenSessionEnergyCannotFormAnInterval(t *testing } tel.DriverHealthMut("charger").SetOffline() srv.qualifyStatusHistory(resp, "site") - if resp["load_w"] != nil || resp["load_estimate"].(telemetry.HomeEstimate).Watts != nil { - t.Fatal("offline charger became zero load") + // The offline charger is left out of live Home use, which says so. + if resp["load_w"] != float64(1234) || resp["load_quality"] != "estimated" || resp["load_estimate"].(telemetry.HomeEstimate).Watts != nil { + t.Fatal("offline charger blanked Home use or became an energy interval") } tel.RecordDriverSuccess("charger") tel.Update("charger", telemetry.DerEV, 6000, nil, nil) diff --git a/go/internal/api/api_support_live_load_test.go b/go/internal/api/api_support_live_load_test.go index 1a08d7845..a88cd9086 100644 --- a/go/internal/api/api_support_live_load_test.go +++ b/go/internal/api/api_support_live_load_test.go @@ -70,7 +70,7 @@ func TestSupportLiveLoadMatchesLANSourcePowerAndKnownZero(t *testing.T) { } func TestSupportUnknownLoadRendersUnavailableWithoutForecastClaims(t *testing.T) { - for _, scenario := range []string{"negative_balance", "declared_never_emitted", "stale_power"} { + for _, scenario := range []string{"negative_balance", "stale_power"} { t.Run(scenario, func(t *testing.T) { s, ctrl, tel := reportTestServer(t) switch scenario { @@ -159,3 +159,28 @@ func TestSupportUnknownLoadNeverBlamesEitherForecast(t *testing.T) { }) } } + +func TestSupportEstimatedLoadRendersEstimateWithoutForecastClaims(t *testing.T) { + s, ctrl, _ := reportTestServer(t) + s.deps.HistoryMeasurementSources = func() telemetry.ForecastOptions { + return telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: "battery", DerType: telemetry.DerBattery}}} + } + if st := supportLoadStatus(t, s); st["load_w"] == nil || st["load_quality"] != "estimated" { + t.Fatal("left-out battery blanked LAN Home use", st) + } + snap := s.liveNow(*ctrl, time.Now()) + if snap.HaveLoad || !snap.LoadEstimated { + t.Fatal("estimated load counted as measured") + } + snap.PredictedLd = 50000 + var b strings.Builder + writeRightNow(&b, *ctrl, snap, nil, nil, control.SlotEnergySnapshot{}, time.Now()) + if !strings.Contains(b.String(), "(estimated: `missing:battery:battery`)") { + t.Fatal("estimate not rendered as one", b.String()) + } + for _, f := range s.collectFindings(*ctrl, snap, nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) { + if strings.Contains(f.Title, "load forecast") { + t.Fatal("estimated load caused a forecast claim") + } + } +} diff --git a/go/internal/api/api_support_report.go b/go/internal/api/api_support_report.go index c78564349..a36ce9be6 100644 --- a/go/internal/api/api_support_report.go +++ b/go/internal/api/api_support_report.go @@ -155,11 +155,15 @@ type liveSnapshot struct { EVW float64 V2XW float64 LoadW float64 - HaveLoad bool - SoCPct float64 - HaveSoC bool - PredictedPV float64 - PredictedLd float64 + // HaveLoad: a measured balance, fit for forecast comparisons. + // LoadEstimated: the site meter is read but a flow was left out. + HaveLoad bool + LoadEstimated bool + LoadReason string + SoCPct float64 + HaveSoC bool + PredictedPV float64 + PredictedLd float64 } func (s *Server) liveNow(ctrl control.State, now time.Time) liveSnapshot { @@ -172,7 +176,8 @@ func (s *Server) liveNow(ctrl control.State, now time.Time) liveSnapshot { GridW: live.GridW, HaveGrid: live.Known[telemetry.DerMeter], PVW: live.PVW, HavePV: live.Known[telemetry.DerPV], BatW: live.BatteryW, HaveBattery: live.Known[telemetry.DerBattery], - EVW: live.EVW, V2XW: live.V2XW, LoadW: live.HouseholdW, HaveLoad: live.Valid, + EVW: live.EVW, V2XW: live.V2XW, LoadW: live.LoadW, HaveLoad: live.LoadKnown && live.LoadQuality == telemetry.LoadMeasured, + LoadEstimated: live.LoadKnown && live.LoadQuality == telemetry.LoadEstimated, LoadReason: live.LoadReason, } var socSum, socWeight float64 for _, r := range s.deps.Tel.ReadingsByType(telemetry.DerBattery) { @@ -316,7 +321,11 @@ func writeRightNow( b.WriteString("| Grid | **no site meter reading** |\n") } fmt.Fprintf(b, "| Solar | %s |\n", fmtReportLiveW(snap.PVW, snap.HavePV)) - fmt.Fprintf(b, "| House load | %s |\n", fmtReportLiveW(snap.LoadW, snap.HaveLoad)) + if snap.LoadEstimated { + fmt.Fprintf(b, "| House load | ≈ %s (estimated: `%s`) |\n", fmtReportW(snap.LoadW), snap.LoadReason) + } else { + fmt.Fprintf(b, "| House load | %s |\n", fmtReportLiveW(snap.LoadW, snap.HaveLoad)) + } fmt.Fprintf(b, "| Battery | %s |\n", fmtReportLiveW(snap.BatW, snap.HaveBattery)) if snap.EVW != 0 { fmt.Fprintf(b, "| EV | %s |\n", fmtReportW(snap.EVW)) @@ -711,11 +720,17 @@ func (s *Server) collectFindings( "guesswork until the meter reports again."}) } - if !snap.HaveLoad { + if snap.LoadEstimated { + out = append(out, finding{sevNote, "Household load is an estimate", + fmt.Sprintf("The site meter is read, but `%s` is left out of the balance. "+ + "Load forecast comparisons are skipped until every flow is read.", snap.LoadReason)}) + } else if !snap.HaveLoad { out = append(out, finding{sevWarning, "Household load is unavailable", "The required power measurements do not form a valid balance. " + "Load forecast comparisons are skipped until that balance is available."}) } + if !snap.HaveLoad { + } if ctrl.PlanStale { out = append(out, finding{sevProblem, "The plan is stale", diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index 303875b54..45f972fca 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -216,8 +216,8 @@ func TestHandleStatusKeepsOfflineFlowsUnavailable(t *testing.T) { // balance. This policy also applies when the config callback is absent. for field, want := range map[string]any{ "grid_w": float64(8000), "ev_w": float64(1000), - "pv_w": nil, "bat_w": nil, "load_w": nil, "bat_soc": nil, - "history_quality": "incomplete", + "pv_w": nil, "bat_w": nil, "load_w": float64(10000), "bat_soc": nil, + "history_quality": "incomplete", "load_quality": "estimated", } { if resp[field] != want { t.Fatalf("%s differs from the qualified live value", field) diff --git a/go/internal/ha/bridge.go b/go/internal/ha/bridge.go index 1faa8e396..b69a36ff3 100644 --- a/go/internal/ha/bridge.go +++ b/go/internal/ha/bridge.go @@ -949,8 +949,8 @@ func (b *Bridge) publishState() { b.publishValue("bat_w", batW) b.publishValue("ev_w", evW) b.publishValue("v2x_w", v2xW) - if live.Valid { - b.publishValue("load_w", live.HouseholdW) + if live.LoadKnown { + b.publishValue("load_w", live.LoadW) } else { // HA's numeric MQTT sensors ignore an empty payload. The literal // None replaces the previous value with unknown. diff --git a/go/internal/ha/live_load_test.go b/go/internal/ha/live_load_test.go index e34687aeb..1ffe8d461 100644 --- a/go/internal/ha/live_load_test.go +++ b/go/internal/ha/live_load_test.go @@ -99,9 +99,9 @@ func TestLoadPublishClearsUnknownAndRecovers(t *testing.T) { } check("1000.00") + // A declared battery that never reported is left out, not a blank. options.ExpectedFlows = []telemetry.ForecastFlow{{Driver: "battery", DerType: telemetry.DerBattery}} - check("None") // HA ignores an empty numeric payload; None clears it to unknown. - check("None") + check("1000.00") b.tel.EnsureDriverHealth("battery") b.tel.Update("battery", telemetry.DerBattery, -200, nil, nil) check("1200.00") @@ -110,8 +110,12 @@ func TestLoadPublishClearsUnknownAndRecovers(t *testing.T) { b.tel.Update("battery", telemetry.DerBattery, 0, nil, nil) check("0.00") + // Unresolved topology still has a read site meter: an estimate. options.HouseholdInvalidReason = "unresolved_topology" - check("None") + check("0.00") + b.tel.DriverHealthMut("meter").SetOffline() + check("None") // HA ignores an empty numeric payload; None clears it to unknown. + b.tel.DriverHealthMut("meter").RecordSuccess() options.HouseholdInvalidReason = "" check("0.00") } diff --git a/go/internal/telemetry/forecast.go b/go/internal/telemetry/forecast.go index b5a31152f..2b2bb5aa0 100644 --- a/go/internal/telemetry/forecast.go +++ b/go/internal/telemetry/forecast.go @@ -45,8 +45,32 @@ type ForecastOptions struct { // Live display may use the driver's measured site-side control power // (for example Pixii AC instead of battery DC). Raw history stays separate. UseControlPower bool `json:",omitempty"` + // PVDark says how dark it is at the site at a time. Live Home use counts + // an unknown PV source as 0 W after sunset. Nil, or no site position, + // infers nothing. Not part of a forecast revision's identity. + PVDark func(time.Time) Darkness `json:"-"` } +// Darkness is the sun's state at the site, for unknown PV. +type Darkness int + +const ( + // DarknessUnknown: daylight, or no site position. + DarknessUnknown Darkness = iota + // DarknessTwilight: after sunset, before civil dusk. Diffuse light only. + DarknessTwilight + // DarknessNight: past civil dusk. PV produces nothing. + DarknessNight +) + +// Live Home use quality. Measured: every flow read. Estimated: the site meter +// is read and a flow is left out or assumed. Unknown: no usable site meter. +const ( + LoadMeasured = "measured" + LoadEstimated = "estimated" + LoadUnknown = "unknown" +) + // ForecastReading is one coherent snapshot, in site signs and raw watts. // Valid describes the household balance. PVValid only describes the PV sources. // Neither flag claims that the instantaneous sample covers a time interval. @@ -54,6 +78,8 @@ type ForecastSource struct { Flow ForecastFlow PowerW *float64 SoC *float64 + // Dark: an unread PV source after sunset, shown as 0 W. Not a reading. + Dark bool } type ForecastReading struct { @@ -63,6 +89,13 @@ type ForecastReading struct { GridW, PVW, BatteryW, EVW, V2XW, HouseholdW float64 Valid, PVValid bool Reason, PVReason string + // Live Home use, separate from history and forecast qualification + // (Valid/Reason). Known whenever the site meter is read; flows that cannot + // be read are left out and LoadQuality says so. PVLiveKnown: every PV + // source is read, or dark. + LoadW float64 + LoadKnown, PVLiveKnown bool + LoadQuality, LoadReason string } // ForecastMeasurement reads power and health under one lock. A command fault @@ -128,6 +161,7 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast out.PVValid = false out.Reason = "no_telemetry" out.PVReason = out.Reason + out.LoadQuality, out.LoadReason = LoadUnknown, out.Reason return out } if opts.MaxAge <= 0 { @@ -136,9 +170,20 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast if opts.MaxSkew <= 0 { opts.MaxSkew = 30 * time.Second } + liveIssue := "" + note := func(reason string) { + if liveIssue == "" { + liveIssue = reason + } + } + pvLiveKnown := true + darkness := DarknessUnknown + if opts.PVDark != nil && !alignSources { + darkness = opts.PVDark(now) + } flows := map[string]ForecastFlow{} key := func(f ForecastFlow) string { return f.Driver + ":" + f.DerType.String() } - fail := func(reason string, pv bool) { + failQuiet := func(reason string, pv bool) { if out.Valid { out.Valid = false out.Reason = reason @@ -148,6 +193,10 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast out.PVReason = reason } } + fail := func(reason string, pv bool) { + note(reason) + failQuiet(reason, pv) + } if opts.PVInvalidReason != "" { out.PVValid = false out.PVReason = opts.PVInvalidReason @@ -188,6 +237,11 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast } } } + // Configuration and duplicate flows leave PV unknown for live use too. + if !out.Known[DerPV] { + pvLiveKnown = false + } + meterIssue := "" keys := make([]string, 0, len(flows)) for k := range flows { keys = append(keys, k) @@ -246,6 +300,25 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast reason = "invalid_sign:" } if reason != "" { + switch { + case pv && darkness != DarknessUnknown: + // Asleep after sunset: 0 W for live use. In twilight Home + // use is an estimate. History and forecasts keep their own + // evidence through Valid/Known. + if darkness == DarknessTwilight { + note("pv_twilight:" + k) + } + failQuiet(reason+k, pv) + out.Known[f.DerType] = false + zero := 0.0 + source.PowerW, source.Dark = &zero, true + out.Sources[k] = source + continue + case pv: + pvLiveKnown = false + case f.DerType == DerMeter: + meterIssue = reason + k + } fail(reason+k, pv) out.Known[f.DerType] = false continue @@ -306,6 +379,8 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast if math.IsNaN(out.HouseholdW) || math.IsInf(out.HouseholdW, 0) || out.HouseholdW < 0 { fail("invalid_balance", false) } + out.PVLiveKnown = pvLiveKnown && pvCount > 0 && !math.IsNaN(out.PVW) && !math.IsInf(out.PVW, 0) + out.setLiveLoad(liveIssue, meterIssue) return out } @@ -409,3 +484,33 @@ func (r ForecastReading) Error() error { } return fmt.Errorf("forecast measurement: %s", r.Reason) } + +// setLiveLoad derives live Home use from the site meter and every flow that +// was read. A missing battery, EV, V2X or daytime PV is left out, as Core did +// before history qualification, and the result is an estimate. It is unknown +// only without a usable site meter or when the read flows do not balance. +func (out *ForecastReading) setLiveLoad(issue, meterIssue string) { + if !out.Known[DerMeter] || meterIssue != "" { + if meterIssue == "" { + meterIssue = "site_meter_unknown" + } + out.LoadKnown, out.LoadQuality, out.LoadReason = false, LoadUnknown, meterIssue + return + } + w := out.HouseholdW + if math.IsNaN(w) || math.IsInf(w, 0) { + out.LoadKnown, out.LoadQuality, out.LoadReason = false, LoadUnknown, "nonfinite_balance" + return + } + if w < 0 { + // A house cannot export. Some flow is wrong; zero would hide it. + out.LoadKnown, out.LoadQuality, out.LoadReason = false, LoadUnknown, "invalid_balance" + return + } + out.LoadKnown, out.LoadW = true, w + if issue == "" { + out.LoadQuality = LoadMeasured + return + } + out.LoadQuality, out.LoadReason = LoadEstimated, issue +} diff --git a/go/internal/telemetry/live_load_quality_test.go b/go/internal/telemetry/live_load_quality_test.go new file mode 100644 index 000000000..df21b8a71 --- /dev/null +++ b/go/internal/telemetry/live_load_quality_test.go @@ -0,0 +1,117 @@ +package telemetry + +import ( + "testing" + "time" +) + +// Anders' site: Pixii reports the site meter and its battery; a Growatt +// inverter drops off the bus after sunset. +func andersSite(darkness Darkness) (*Store, ForecastOptions) { + s := NewStore() + for _, d := range []string{"pixii", "growatt"} { + s.EnsureDriverHealth(d) + } + s.Update("pixii", DerMeter, 780, nil, nil) + s.Update("pixii", DerBattery, -200, nil, nil) + s.RecordDriverSuccess("pixii") + s.Update("growatt", DerPV, -1500, nil, nil) + s.DriverHealthMut("growatt").SetOffline() + return s, ForecastOptions{PVDark: func(time.Time) Darkness { return darkness }} +} + +func TestLiveLoadAndersNightOfflinePVIsZero(t *testing.T) { + s, opts := andersSite(DarknessNight) + r := s.LiveMeasurementNow("pixii", opts) + if !r.LoadKnown || r.LoadW != 980 || r.LoadQuality != LoadMeasured || r.LoadReason != "" { + t.Fatalf("night load = %v known=%t %s %q", r.LoadW, r.LoadKnown, r.LoadQuality, r.LoadReason) + } + if !r.PVLiveKnown || r.PVW != 0 { + t.Fatalf("dark PV not shown as 0 W: known=%t pv=%v", r.PVLiveKnown, r.PVW) + } + if src := r.Sources["growatt:pv"]; !src.Dark || src.PowerW == nil || *src.PowerW != 0 { + t.Fatal("dark source not marked 0 W") + } + // History and forecasts keep their own evidence. + if r.Valid || r.Known[DerPV] || r.Reason != "offline:growatt:pv" { + t.Fatalf("live display changed history qualification: valid=%t reason=%q", r.Valid, r.Reason) + } + if f := s.ForecastMeasurementNow("pixii", opts); f.Valid || f.Known[DerPV] { + t.Fatal("forecast path assumed dark PV") + } +} + +func TestLiveLoadTwilightOfflinePVIsEstimate(t *testing.T) { + s, opts := andersSite(DarknessTwilight) + r := s.LiveMeasurementNow("pixii", opts) + if !r.LoadKnown || r.LoadW != 980 || r.LoadQuality != LoadEstimated || r.LoadReason != "pv_twilight:growatt:pv" || !r.PVLiveKnown { + t.Fatalf("twilight: %v %t %s %q", r.LoadW, r.LoadKnown, r.LoadQuality, r.LoadReason) + } +} + +func TestLiveLoadDaytimeOfflinePVIsEstimateWithUnknownPV(t *testing.T) { + for _, opts := range []ForecastOptions{{}, {PVDark: func(time.Time) Darkness { return DarknessUnknown }}} { + s, _ := andersSite(DarknessUnknown) + r := s.LiveMeasurementNow("pixii", opts) + if !r.LoadKnown || r.LoadW != 980 || r.LoadQuality != LoadEstimated || r.LoadReason != "offline:growatt:pv" { + t.Fatalf("day: %v %t %s %q", r.LoadW, r.LoadKnown, r.LoadQuality, r.LoadReason) + } + if r.PVLiveKnown { + t.Fatal("daytime offline PV shown as known") + } + } +} + +func TestLiveLoadOfflineSiteMeterIsUnknown(t *testing.T) { + s, opts := andersSite(DarknessNight) + s.DriverHealthMut("pixii").SetOffline() + r := s.LiveMeasurementNow("pixii", opts) + if r.LoadKnown || r.LoadQuality != LoadUnknown || r.LoadReason != "offline:pixii:meter" { + t.Fatalf("offline meter: known=%t %s %q", r.LoadKnown, r.LoadQuality, r.LoadReason) + } + if r := s.LiveMeasurementNow("nometer", ForecastOptions{}); r.LoadKnown || r.LoadReason != "missing:nometer:meter" { + t.Fatalf("missing meter: known=%t %q", r.LoadKnown, r.LoadReason) + } +} + +func TestLiveLoadStaleSiteMeterIsUnknown(t *testing.T) { + s, opts := andersSite(DarknessNight) + s.Get("pixii", DerMeter).UpdatedAt = time.Now().Add(-10 * time.Minute) + if r := s.LiveMeasurementNow("pixii", opts); r.LoadKnown || r.LoadReason != "stale:pixii:meter" { + t.Fatalf("stale meter: known=%t %q", r.LoadKnown, r.LoadReason) + } +} + +// #1744: a loadpoint bound to a charger name with no driver. +func TestLiveLoadNonexistentChargerIsLeftOut(t *testing.T) { + s := NewStore() + s.EnsureDriverHealth("meter") + s.Update("meter", DerMeter, 1500, nil, nil) + opts := ForecastOptions{ExpectedFlows: []ForecastFlow{{Driver: "easee", DerType: DerEV}}} + r := s.LiveMeasurementNow("meter", opts) + if !r.LoadKnown || r.LoadW != 1500 || r.LoadQuality != LoadEstimated || r.LoadReason != "missing:easee:ev" { + t.Fatalf("#1744: %v %t %s %q", r.LoadW, r.LoadKnown, r.LoadQuality, r.LoadReason) + } + if r.Valid || r.Known[DerEV] { + t.Fatal("history qualification accepted the missing charger") + } +} + +func TestLiveLoadOfflineBatteryLeftOut(t *testing.T) { + s, opts := andersSite(DarknessNight) + s.EnsureDriverHealth("bat2") + s.Update("bat2", DerBattery, -3000, nil, nil) + s.DriverHealthMut("bat2").SetOffline() + r := s.LiveMeasurementNow("pixii", opts) + if !r.LoadKnown || r.LoadW != 980 || r.LoadQuality != LoadEstimated || r.LoadReason != "offline:bat2:battery" { + t.Fatalf("offline battery: %v %t %s %q", r.LoadW, r.LoadKnown, r.LoadQuality, r.LoadReason) + } +} + +func TestLiveLoadNegativeBalanceIsUnknown(t *testing.T) { + s, opts := andersSite(DarknessNight) + s.Update("pixii", DerBattery, 2000, nil, nil) + if r := s.LiveMeasurementNow("pixii", opts); r.LoadKnown || r.LoadReason != "invalid_balance" { + t.Fatalf("negative balance: known=%t %q", r.LoadKnown, r.LoadReason) + } +} diff --git a/web/app.js b/web/app.js index e0be5262e..29621381d 100644 --- a/web/app.js +++ b/web/app.js @@ -722,7 +722,8 @@ 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"; + loadDir.textContent = data.load_w == null || !isFinite(data.load_w) ? "no data" + : data.load_quality === "estimated" ? "estimated" : "using now"; } // EV tile (tile-mode parity with the energy-flow's EV planet). diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index 0ccd48a8c..baacf1830 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -207,7 +207,8 @@ export function flowReadingsFromStatus(status, opts) { if (!live[role]) planets.push(...offline[role]); } - const hasEstimate = status?.load_estimate != null; + // Core says "estimated" when it left an unread device out of Home use. + const hasEstimate = status?.load_estimate != null || status?.load_quality === "estimated"; // Core qualifies this balance, including missing flows and stale meters. // A past energy interval is separate from Power now: it must neither hide // nor overwrite current household power. Never derive another balance from diff --git a/web/energy-flow-readings.test.mjs b/web/energy-flow-readings.test.mjs index 4af56e4c3..deb688e6b 100644 --- a/web/energy-flow-readings.test.mjs +++ b/web/energy-flow-readings.test.mjs @@ -226,3 +226,11 @@ it('marks only devices that need attention and keeps an offline sibling visible assert.equal(failed.clickable,true); assert.equal(flowReadingsFromStatus(status,{live:false}).planets.find(p=>p.name==='b').controlMark,undefined); }); + +it("draws Core's estimated Home use as an estimate, not a blank", () => { + const status = {load_w: 980, load_quality: "estimated", load_reason: "offline:growatt:pv", history_quality: "incomplete"}; + const result = flowReadingsFromStatus(status); + assert.equal(result.load, 0.98); + assert.equal(result.loadEstimated, true); + assert.equal(flowReadingsFromStatus({load_w: 980, load_quality: "measured"}).loadEstimated, false); +}); From 786f55e2cd5c6e763babe31d38d4f28853220b65 Mon Sep 17 00:00:00 2001 From: "frahlg-agents[bot]" Date: Wed, 21 Oct 2026 06:53:14 +0200 Subject: [PATCH 2/4] fix(live): darkness stands in only for unread PV, not malformed readings --- go/internal/telemetry/forecast.go | 12 +++++++++++- go/internal/telemetry/live_load_quality_test.go | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/go/internal/telemetry/forecast.go b/go/internal/telemetry/forecast.go index 2b2bb5aa0..5c6adff49 100644 --- a/go/internal/telemetry/forecast.go +++ b/go/internal/telemetry/forecast.go @@ -301,7 +301,7 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast } if reason != "" { switch { - case pv && darkness != DarknessUnknown: + case pv && darkness != DarknessUnknown && pvUnread(reason): // Asleep after sunset: 0 W for live use. In twilight Home // use is an estimate. History and forecasts keep their own // evidence through Valid/Known. @@ -514,3 +514,13 @@ func (out *ForecastReading) setLiveLoad(issue, meterIssue string) { } out.LoadQuality, out.LoadReason = LoadEstimated, issue } + +// pvUnread: the PV source gave no usable reading. Only then can darkness stand +// in for it. A malformed reading (wrong sign, non-finite, future) stays a fault. +func pvUnread(reason string) bool { + switch reason { + case "missing:", "offline:", "stale:", "unknown_power:": + return true + } + return false +} diff --git a/go/internal/telemetry/live_load_quality_test.go b/go/internal/telemetry/live_load_quality_test.go index df21b8a71..ecf1808e8 100644 --- a/go/internal/telemetry/live_load_quality_test.go +++ b/go/internal/telemetry/live_load_quality_test.go @@ -115,3 +115,13 @@ func TestLiveLoadNegativeBalanceIsUnknown(t *testing.T) { t.Fatalf("negative balance: known=%t %q", r.LoadKnown, r.LoadReason) } } + +func TestLiveLoadDarkDoesNotHideMalformedPV(t *testing.T) { + s, opts := andersSite(DarknessNight) + s.DriverHealthMut("growatt").RecordSuccess() + s.Get("growatt", DerPV).RawW = 300 // positive PV: a sign fault Update would refuse + r := s.LiveMeasurementNow("pixii", opts) + if r.PVLiveKnown || r.Sources["growatt:pv"].Dark || r.LoadQuality != LoadEstimated || r.LoadReason != "invalid_sign:growatt:pv" { + t.Fatalf("sign fault hidden at night: pv=%t %s %q", r.PVLiveKnown, r.LoadQuality, r.LoadReason) + } +} From 0b0c0968b730b3f3be63cd4337d30aee43aaac6a Mon Sep 17 00:00:00 2001 From: "frahlg-agents[bot]" Date: Wed, 21 Oct 2026 07:59:59 +0200 Subject: [PATCH 3/4] fix(live): show the balance as is; log display load apart from training load; HA energy-average sensor --- .changeset/live-home-use-quality.md | 4 +- go/cmd/ftw/display_load.go | 26 +++++++ go/cmd/ftw/display_load_test.go | 74 +++++++++++++++++++ go/cmd/ftw/live_load_test.go | 2 +- go/cmd/ftw/main.go | 6 ++ go/internal/api/api_history_quality_test.go | 3 +- go/internal/api/api_home_estimate_test.go | 11 +-- go/internal/api/api_support_live_load_test.go | 2 +- go/internal/ha/bridge.go | 10 +++ go/internal/ha/live_load_test.go | 32 ++++++++ go/internal/telemetry/forecast.go | 12 +-- .../telemetry/live_load_quality_test.go | 15 +++- web/components/energy-flow-readings.js | 3 +- web/energy-flow-readings.test.mjs | 4 +- 14 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 go/cmd/ftw/display_load.go create mode 100644 go/cmd/ftw/display_load_test.go diff --git a/.changeset/live-home-use-quality.md b/.changeset/live-home-use-quality.md index 134962f9b..7d2e05f2d 100644 --- a/.changeset/live-home-use-quality.md +++ b/.changeset/live-home-use-quality.md @@ -2,4 +2,6 @@ "ftw": patch --- -Show Home use again when one device is offline. Live Home use now comes from the site meter and every device that is read, as before 0.141. A battery, charger or daytime inverter that is offline is left out and Home use is marked estimated; the dashboard shows "estimated" and "≈" instead of a blank. After sunset at the site's map pin an offline inverter counts as 0 W. Home use is unknown only when the site meter is missing or stale, or the readings do not balance. `/api/status` adds `load_quality` (measured, estimated, unknown) and `load_reason`. History and forecast quality are unchanged. +Show Home use again when one device is offline. Live Home use is the energy balance as is: the site meter minus every device that is read, as before 0.141. A device that is offline, or a setup problem, no longer blanks it; the dashboard marks it "estimated" with "≈". A negative value is shown, since it points at production FTW cannot see. After sunset at the site's map pin an offline inverter counts as 0 W. Home use is blank only when the site meter is missing or stale. `/api/status` adds `load_quality` and `load_reason` as hints. + +Home use now has two series. The live display value is logged as the `load_display_w` metric. The energy-based Home use from closed history intervals, with its quality flags, stays what load models train on. Home Assistant gets a second sensor, "Load Power (energy average)", next to "Load Power". diff --git a/go/cmd/ftw/display_load.go b/go/cmd/ftw/display_load.go new file mode 100644 index 000000000..073ceade6 --- /dev/null +++ b/go/cmd/ftw/display_load.go @@ -0,0 +1,26 @@ +package main + +import ( + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Display load is stored as a Core metric series, apart from every driver. +const ( + displayLoadDriver = "ftw:site" + displayLoadMetric = "load_display_w" +) + +// displayLoadSample is the live Home use clients show, for the metric store. +// Nothing while the site meter is unknown: that is what clients show too. +func displayLoadSample(tel *telemetry.Store, siteMeter string, nowMs int64, options telemetry.ForecastOptions, sun historySun) (state.Sample, bool) { + if tel == nil { + return state.Sample{}, false + } + options.PVDark = sun.darkness + live := tel.LiveMeasurementNow(siteMeter, options) + if !live.LoadKnown { + return state.Sample{}, false + } + return state.Sample{Driver: displayLoadDriver, Metric: displayLoadMetric, TsMs: nowMs, Value: live.LoadW, Unit: "W"}, true +} diff --git a/go/cmd/ftw/display_load_test.go b/go/cmd/ftw/display_load_test.go new file mode 100644 index 000000000..4d45690dc --- /dev/null +++ b/go/cmd/ftw/display_load_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Two series: the live display load is logged as a Core metric exactly as +// clients show it (negative included). The history tick's Load stays the +// placeholder the source-energy writer replaces, so training, which reads +// HouseholdIntervals, never sees the display value. +func TestDisplayLoadIsLoggedApartFromTrainingLoad(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) + } + tel := telemetry.NewStore() + for _, d := range []string{"meter", "battery"} { + tel.EnsureDriverHealth(d) + } + tel.Update("meter", telemetry.DerMeter, 1200, nil, nil) + tel.Update("battery", telemetry.DerBattery, 2000, nil, nil) // negative balance + tel.RecordDriverSuccess("meter") + tel.RecordDriverSuccess("battery") + ctrl := tickPersistControl{SiteMeterDriver: "meter"} + at := time.Now().UnixMilli() + if _, err := persistTelemetryTick(st, tel, ctrl, at, time.Minute, testEnergyIdentity(st)); err != nil { + t.Fatal(err) + } + if err := st.FlushHistory(context.Background()); err != nil { + t.Fatal(err) + } + var got []state.Sample + for i := 0; i < 50 && len(got) == 0; i++ { + got, err = st.LoadSeries(displayLoadDriver, displayLoadMetric, at-1, at+1, 0) + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + time.Sleep(20 * time.Millisecond) + } + } + if len(got) != 1 || got[0].Value != -800 { + t.Fatalf("display load series = %+v, want one -800 W sample", got) + } + history, err := st.LoadHistory(at-1, at+1, 0) + if err != nil { + t.Fatal(err) + } + for _, h := range history { + if h.Missing&state.MissingLoad == 0 || h.LoadW != 0 { + t.Fatalf("display load leaked into the history Load column: %+v", h) + } + } + if intervals, _, err := st.HouseholdIntervals(context.Background(), 0, at, 15*60000); err != nil || len(intervals) != 0 { + t.Fatalf("training intervals from one display tick: %v %v", intervals, err) + } +} + +func TestDisplayLoadNotLoggedWithoutSiteMeter(t *testing.T) { + tel := telemetry.NewStore() + if _, ok := displayLoadSample(tel, "meter", time.Now().UnixMilli(), telemetry.ForecastOptions{}, historySun{}); ok { + t.Fatal("display load logged without a site meter") + } +} diff --git a/go/cmd/ftw/live_load_test.go b/go/cmd/ftw/live_load_test.go index 1771e8076..c1f9a22f7 100644 --- a/go/cmd/ftw/live_load_test.go +++ b/go/cmd/ftw/live_load_test.go @@ -76,7 +76,7 @@ func TestLiveLoadAppAndLANShareSourcePowerAndValidity(t *testing.T) { want = 1500 case "negative": tel.Update("battery", telemetry.DerBattery, 2000, nil, nil) - valid = false + want = -1000 // shown as is: production Core cannot see case "offline_ev": tel.Update("charger", telemetry.DerEV, 500, nil, nil) tel.DriverHealthMut("charger").SetOffline() diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 594b52459..b35667287 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -4036,6 +4036,12 @@ func persistTelemetryTick(st *state.Store, tel *telemetry.Store, ctrl tickPersis Value: sm.Value, Unit: sm.Unit, } } + // Display load: the live balance exactly as clients show it. A record of + // what was on screen, never a training label; models read the household + // energy intervals the source writer closes (HouseholdIntervals). + if display, ok := displayLoadSample(tel, ctrl.SiteMeterDriver, nowMs, options, sun); ok { + stSamples = append(stSamples, display) + } energyObservations := buildEnergyObservations(st, tel, ctrl, hp, identity) if hp.Sources != nil { hp.Sources.Topology += historyDevices(st, tel, identity, energyObservations, dark) diff --git a/go/internal/api/api_history_quality_test.go b/go/internal/api/api_history_quality_test.go index 77c6b1c70..9822e7a58 100644 --- a/go/internal/api/api_history_quality_test.go +++ b/go/internal/api/api_history_quality_test.go @@ -84,7 +84,8 @@ func TestLiveHistoryQualitySeparatesOfflineFromInvalidBalance(t *testing.T) { tel.Update("battery", telemetry.DerBattery, 2000, nil, nil) tel.RecordDriverSuccess("battery") srv.qualifyStatusHistory(resp, "meter") - if resp["bat_w"] != float64(2000) || resp["load_w"] != nil || resp["history_quality"] != "balance_mismatch" { + // Negative balance: shown as is, history still records the mismatch. + if resp["bat_w"] != float64(2000) || resp["load_w"] != float64(-800) || resp["load_reason"] != "invalid_balance" || resp["history_quality"] != "balance_mismatch" { t.Fatal(resp) } tel.Update("battery", telemetry.DerBattery, 0, nil, nil) diff --git a/go/internal/api/api_home_estimate_test.go b/go/internal/api/api_home_estimate_test.go index b040d124c..b50302d2e 100644 --- a/go/internal/api/api_home_estimate_test.go +++ b/go/internal/api/api_home_estimate_test.go @@ -72,8 +72,8 @@ func TestStatusKeepsPowerBalanceWhenSessionEnergyCannotFormAnInterval(t *testing tel.RecordDriverSuccess("charger") tel.Update("charger", telemetry.DerEV, 6000, nil, nil) srv.qualifyStatusHistory(resp, "site") - if resp["load_w"] != nil || resp["history_reason"] != "invalid_balance" { - t.Fatal("negative residual became a zero household balance") + if resp["load_w"] != float64(1234-6000) || resp["history_reason"] != "invalid_balance" { + t.Fatal("negative residual was hidden or zeroed") } } @@ -97,12 +97,13 @@ func TestStatusHomePowerUsesPixiiACWithoutChangingRawHistoryInputs(t *testing.T) } tel.Update("battery", telemetry.DerBattery, -2500, nil, json.RawMessage(`{"control_power_available":false}`)) srv.qualifyStatusHistory(resp, "site") - if resp["load_w"] != nil || resp["bat_w"] != nil { - t.Fatal("missing AC measurement became DC power or zero") + // Unknown AC power leaves the battery out; the balance is shown as is. + if resp["load_w"] != float64(5000-6000) || resp["bat_w"] != nil || resp["load_quality"] != "estimated" { + t.Fatal("missing AC measurement became DC power or hid the balance") } tel.Update("battery", telemetry.DerBattery, -2500, nil, json.RawMessage(`{"control_power_w":-500,"control_power_available":true}`)) srv.qualifyStatusHistory(resp, "site") - if resp["load_w"] != nil || resp["history_reason"] != "invalid_balance" { + if resp["load_w"] != float64(5000-(-500)-6000) || resp["history_reason"] != "invalid_balance" { t.Fatal("negative AC balance became zero or a DC estimate") } } diff --git a/go/internal/api/api_support_live_load_test.go b/go/internal/api/api_support_live_load_test.go index a88cd9086..76ab304e8 100644 --- a/go/internal/api/api_support_live_load_test.go +++ b/go/internal/api/api_support_live_load_test.go @@ -70,7 +70,7 @@ func TestSupportLiveLoadMatchesLANSourcePowerAndKnownZero(t *testing.T) { } func TestSupportUnknownLoadRendersUnavailableWithoutForecastClaims(t *testing.T) { - for _, scenario := range []string{"negative_balance", "stale_power"} { + for _, scenario := range []string{"stale_power"} { t.Run(scenario, func(t *testing.T) { s, ctrl, tel := reportTestServer(t) switch scenario { diff --git a/go/internal/ha/bridge.go b/go/internal/ha/bridge.go index b69a36ff3..f85231428 100644 --- a/go/internal/ha/bridge.go +++ b/go/internal/ha/bridge.go @@ -492,7 +492,10 @@ func (b *Bridge) publishDiscovery() { {"battery_power", "Battery Power", "W", "power", "measurement", b.stateTopic("bat_w")}, {"ev_power", "EV Power", "W", "power", "measurement", b.stateTopic("ev_w")}, {"v2x_power", "V2X Power", "W", "power", "measurement", b.stateTopic("v2x_w")}, + // Live balance as the dashboard shows it, and the energy-based mean + // of the last closed window (the quality-assured Home use). {"load_power", "Load Power", "W", "power", "measurement", b.stateTopic("load_w")}, + {"load_energy_power", "Load Power (energy average)", "W", "power", "measurement", b.stateTopic("load_energy_w")}, {"battery_soc", "Battery SoC", "%", "battery", "measurement", b.stateTopic("bat_soc_pct")}, // Control setpoints {"grid_target", "Grid Target", "W", "power", "", b.stateTopic("grid_target_w")}, @@ -956,6 +959,13 @@ func (b *Bridge) publishState() { // None replaces the previous value with unknown. b.publishString("load_w", "None") } + energyOpts := options + energyOpts.UseWatchdogTimeout, energyOpts.UseControlPower = true, true + if e := b.tel.HomeUseEstimate(time.Now(), siteMeter, energyOpts); e.Watts != nil { + b.publishValue("load_energy_w", *e.Watts) + } else { + b.publishString("load_energy_w", "None") + } b.publishValue("bat_soc_pct", units.PercentFromFraction(avgSoC)) b.publishValue("grid_target_w", gridTarget) b.publishValue("peak_limit_w", peakLimit) diff --git a/go/internal/ha/live_load_test.go b/go/internal/ha/live_load_test.go index 1ffe8d461..a0232b2a6 100644 --- a/go/internal/ha/live_load_test.go +++ b/go/internal/ha/live_load_test.go @@ -2,6 +2,7 @@ package ha import ( "encoding/json" + "strings" "sync" "testing" "time" @@ -119,3 +120,34 @@ func TestLoadPublishClearsUnknownAndRecovers(t *testing.T) { options.HouseholdInvalidReason = "" check("0.00") } + +// HA gets two Home use series: the live balance as the dashboard shows it +// (negative included) and the energy-based mean (unknown until a full window). +func TestLoadPublishesDisplayAndEnergySeries(t *testing.T) { + b, client := liveLoadBridge(nil) + for _, d := range []string{"meter", "battery"} { + b.tel.EnsureDriverHealth(d) + } + b.tel.Update("meter", telemetry.DerMeter, 1200, nil, nil) + b.tel.Update("battery", telemetry.DerBattery, 2000, nil, nil) + b.publishState() + if got := client.last(t, b.stateTopic("load_w")); got != "-800.00" { + t.Fatalf("display load = %s, want the negative balance as is", got) + } + if got := client.last(t, b.stateTopic("load_energy_w")); got != "None" { + t.Fatalf("energy load without a window = %s, want None", got) + } + b.publishDiscovery() + var display, energy bool + client.mu.Lock() + for topic, payloads := range client.payloads { + for _, p := range payloads { + display = display || strings.Contains(topic, "load_power/config") && strings.Contains(p, b.stateTopic("load_w")) + energy = energy || strings.Contains(topic, "load_energy_power/config") && strings.Contains(p, b.stateTopic("load_energy_w")) + } + } + client.mu.Unlock() + if !display || !energy { + t.Fatalf("discovery: display=%t energy=%t", display, energy) + } +} diff --git a/go/internal/telemetry/forecast.go b/go/internal/telemetry/forecast.go index 5c6adff49..8294e58e2 100644 --- a/go/internal/telemetry/forecast.go +++ b/go/internal/telemetry/forecast.go @@ -488,7 +488,7 @@ func (r ForecastReading) Error() error { // setLiveLoad derives live Home use from the site meter and every flow that // was read. A missing battery, EV, V2X or daytime PV is left out, as Core did // before history qualification, and the result is an estimate. It is unknown -// only without a usable site meter or when the read flows do not balance. +// only without a usable site meter. Quality is a hint and never hides it. func (out *ForecastReading) setLiveLoad(issue, meterIssue string) { if !out.Known[DerMeter] || meterIssue != "" { if meterIssue == "" { @@ -502,12 +502,12 @@ func (out *ForecastReading) setLiveLoad(issue, meterIssue string) { out.LoadKnown, out.LoadQuality, out.LoadReason = false, LoadUnknown, "nonfinite_balance" return } - if w < 0 { - // A house cannot export. Some flow is wrong; zero would hide it. - out.LoadKnown, out.LoadQuality, out.LoadReason = false, LoadUnknown, "invalid_balance" - return - } + // Shown as is. A negative balance is a hint of production Core cannot + // see; zero or blank would hide it. out.LoadKnown, out.LoadW = true, w + if w < 0 && issue == "" { + issue = "invalid_balance" + } if issue == "" { out.LoadQuality = LoadMeasured return diff --git a/go/internal/telemetry/live_load_quality_test.go b/go/internal/telemetry/live_load_quality_test.go index ecf1808e8..36e75e97d 100644 --- a/go/internal/telemetry/live_load_quality_test.go +++ b/go/internal/telemetry/live_load_quality_test.go @@ -108,11 +108,20 @@ func TestLiveLoadOfflineBatteryLeftOut(t *testing.T) { } } -func TestLiveLoadNegativeBalanceIsUnknown(t *testing.T) { +func TestLiveLoadNegativeBalanceIsShown(t *testing.T) { s, opts := andersSite(DarknessNight) s.Update("pixii", DerBattery, 2000, nil, nil) - if r := s.LiveMeasurementNow("pixii", opts); r.LoadKnown || r.LoadReason != "invalid_balance" { - t.Fatalf("negative balance: known=%t %q", r.LoadKnown, r.LoadReason) + if r := s.LiveMeasurementNow("pixii", opts); !r.LoadKnown || r.LoadW != -1220 || r.LoadQuality != LoadEstimated || r.LoadReason != "invalid_balance" { + t.Fatalf("negative balance: %v known=%t %q", r.LoadW, r.LoadKnown, r.LoadReason) + } +} + +func TestLiveLoadMisconfigurationStillShowsValue(t *testing.T) { + s, opts := andersSite(DarknessNight) + opts.HouseholdInvalidReason = "unresolved_topology" + opts.ExpectedFlows = []ForecastFlow{{Driver: "ghost", DerType: DerBattery}, {Driver: "ghost-ev", DerType: DerEV}} + if r := s.LiveMeasurementNow("pixii", opts); !r.LoadKnown || r.LoadW != 980 || r.LoadQuality != LoadEstimated { + t.Fatalf("misconfiguration hid the balance: %v known=%t %q", r.LoadW, r.LoadKnown, r.LoadReason) } } diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index baacf1830..ca942b21f 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -214,7 +214,8 @@ export function flowReadingsFromStatus(status, opts) { // nor overwrite current household power. Never derive another balance from // independently smoothed display readings here. const rawLoad = num(status?.load_w); - const powerBalanceW = rawLoad !== null && rawLoad >= 0 ? rawLoad : null; + // Shown as Core sends it. Negative means production Core cannot see. + const powerBalanceW = rawLoad; const loadW = powerBalanceW; // Today's share is kWh already in the box, not the live meter. A // quiet meter blanks % SELF-POWERED NOW (unknown instantaneous load) diff --git a/web/energy-flow-readings.test.mjs b/web/energy-flow-readings.test.mjs index deb688e6b..027f1be65 100644 --- a/web/energy-flow-readings.test.mjs +++ b/web/energy-flow-readings.test.mjs @@ -38,7 +38,9 @@ it("keeps a qualified power balance visible while the energy interval is unavail it("distinguishes a real zero balance from missing, invalid or stale inputs", () => { const base = {history_quality:"complete",load_estimate:{watts:null,reason:"incomplete_source_window"}}; assert.equal(flowReadingsFromStatus({...base,load_w:0}).load,0); - for (const value of [undefined, null, "0", NaN, Infinity, -1]) { + // A negative balance is shown: it points at production Core cannot see. + assert.equal(flowReadingsFromStatus({...base,load_w:-300}).load,-0.3); + for (const value of [undefined, null, "0", NaN, Infinity]) { const result = flowReadingsFromStatus({...base,load_w:value}); assert.equal(result.load,null); } From 7ca86369cb0f7b0e70c7d743ad7a88f4bfdde6c5 Mon Sep 17 00:00:00 2001 From: "frahlg-agents[bot]" Date: Wed, 21 Oct 2026 08:02:21 +0200 Subject: [PATCH 4/4] fix(live): leave duplicated flows out of the live balance --- go/internal/api/api_support_report.go | 2 -- go/internal/telemetry/forecast.go | 13 ++++++++++--- go/internal/telemetry/live_load_quality_test.go | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/go/internal/api/api_support_report.go b/go/internal/api/api_support_report.go index a36ce9be6..9741f1f08 100644 --- a/go/internal/api/api_support_report.go +++ b/go/internal/api/api_support_report.go @@ -729,8 +729,6 @@ func (s *Server) collectFindings( "The required power measurements do not form a valid balance. " + "Load forecast comparisons are skipped until that balance is available."}) } - if !snap.HaveLoad { - } if ctrl.PlanStale { out = append(out, finding{sevProblem, "The plan is stale", diff --git a/go/internal/telemetry/forecast.go b/go/internal/telemetry/forecast.go index 8294e58e2..ba1e33926 100644 --- a/go/internal/telemetry/forecast.go +++ b/go/internal/telemetry/forecast.go @@ -214,6 +214,9 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast flows[key(f)] = f } } + // Kinds configured twice: live Home use leaves them out, never subtracts twice. + duplicate := map[DerType]bool{} + var duplicateW float64 ids := map[string]ForecastFlow{} for _, f := range opts.ExpectedFlows { if f.DerType == DerVehicle || (f.DerType == DerMeter && f.Driver != siteMeter) { @@ -223,6 +226,7 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast if old, ok := ids[f.FlowID]; ok { fail("duplicate_flow:"+f.FlowID, f.DerType == DerPV || old.DerType == DerPV) out.Known[f.DerType], out.Known[old.DerType] = false, false + duplicate[f.DerType], duplicate[old.DerType] = true, true } ids[f.FlowID] = f } @@ -234,6 +238,7 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast if _, ok := flows[f.Driver+":"+DerV2X.String()]; ok { fail("duplicate_charger:"+f.Driver, false) out.Known[DerEV], out.Known[DerV2X] = false, false + duplicate[DerEV], duplicate[DerV2X] = true, true } } } @@ -343,6 +348,9 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast pvLast = powerAt } } + if duplicate[f.DerType] { + duplicateW += powerW + } switch f.DerType { case DerMeter: out.GridW = powerW @@ -380,7 +388,7 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast fail("invalid_balance", false) } out.PVLiveKnown = pvLiveKnown && pvCount > 0 && !math.IsNaN(out.PVW) && !math.IsInf(out.PVW, 0) - out.setLiveLoad(liveIssue, meterIssue) + out.setLiveLoad(out.HouseholdW+duplicateW, liveIssue, meterIssue) return out } @@ -489,7 +497,7 @@ func (r ForecastReading) Error() error { // was read. A missing battery, EV, V2X or daytime PV is left out, as Core did // before history qualification, and the result is an estimate. It is unknown // only without a usable site meter. Quality is a hint and never hides it. -func (out *ForecastReading) setLiveLoad(issue, meterIssue string) { +func (out *ForecastReading) setLiveLoad(w float64, issue, meterIssue string) { if !out.Known[DerMeter] || meterIssue != "" { if meterIssue == "" { meterIssue = "site_meter_unknown" @@ -497,7 +505,6 @@ func (out *ForecastReading) setLiveLoad(issue, meterIssue string) { out.LoadKnown, out.LoadQuality, out.LoadReason = false, LoadUnknown, meterIssue return } - w := out.HouseholdW if math.IsNaN(w) || math.IsInf(w, 0) { out.LoadKnown, out.LoadQuality, out.LoadReason = false, LoadUnknown, "nonfinite_balance" return diff --git a/go/internal/telemetry/live_load_quality_test.go b/go/internal/telemetry/live_load_quality_test.go index 36e75e97d..c6a89d5ca 100644 --- a/go/internal/telemetry/live_load_quality_test.go +++ b/go/internal/telemetry/live_load_quality_test.go @@ -134,3 +134,17 @@ func TestLiveLoadDarkDoesNotHideMalformedPV(t *testing.T) { t.Fatalf("sign fault hidden at night: pv=%t %s %q", r.PVLiveKnown, r.LoadQuality, r.LoadReason) } } + +func TestLiveLoadDuplicateChargerIsNotSubtractedTwice(t *testing.T) { + s := NewStore() + for _, d := range []string{"meter", "wallbox"} { + s.EnsureDriverHealth(d) + } + s.Update("meter", DerMeter, 5000, nil, nil) + s.Update("wallbox", DerEV, 3000, nil, nil) + s.Update("wallbox", DerV2X, 3000, nil, nil) + r := s.LiveMeasurementNow("meter", ForecastOptions{}) + if !r.LoadKnown || r.LoadW != 5000 || r.LoadQuality != LoadEstimated || r.LoadReason != "duplicate_charger:wallbox" { + t.Fatalf("duplicate charger: %v known=%t %q", r.LoadW, r.LoadKnown, r.LoadReason) + } +}