From cfbc7462a95c3bc85ab2d662ff49ec11a3143db3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 18:23:21 +0200 Subject: [PATCH 1/6] fix(appproto): omit unknown Core load from live fields --- go/internal/appproto/harness_test.go | 1 + go/internal/appproto/phases_test.go | 1 + go/internal/appproto/ports.go | 3 + go/internal/appproto/telemetry.go | 4 +- go/internal/appproto/telemetry_test.go | 81 ++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) diff --git a/go/internal/appproto/harness_test.go b/go/internal/appproto/harness_test.go index c193c8d78..2ab9dba1d 100644 --- a/go/internal/appproto/harness_test.go +++ b/go/internal/appproto/harness_test.go @@ -302,6 +302,7 @@ func newRigWith(t *testing.T, prefs PlannerPrefs) (*Handler, *fakeBox, *recorder PVW: -3400, BatteryW: 900, LoadW: 3700, + LoadWKnown: true, BatterySoC: 0.62, BatterySoCKnown: true, Sources: []Source{ diff --git a/go/internal/appproto/phases_test.go b/go/internal/appproto/phases_test.go index 0e29597e4..31d2e63fc 100644 --- a/go/internal/appproto/phases_test.go +++ b/go/internal/appproto/phases_test.go @@ -103,6 +103,7 @@ func TestTheExtraFieldsGiveWayFirst(t *testing.T) { h, _, _, _ := newRig(t) full := fieldValues(Snapshot{ GridW: 1200, PVW: -3400, BatteryW: 900, LoadW: 3700, + LoadWKnown: true, BatterySoC: 0.62, BatterySoCKnown: true, EVWKnown: true, EVW: 7360, GridPhaseA: amps(-21.3, 47.6, -16.1), FuseA: 20, }, nil) diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 050e07497..aac78eb20 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -83,6 +83,9 @@ type Snapshot struct { PVW float64 BatteryW float64 LoadW float64 + // LoadWKnown says Core has a valid live load estimate. Unknown load is + // omitted from field 6; a known zero remains a real reading. + LoadWKnown bool // BatterySoC is a fraction in [0,1], per the SI rule. The wire carries // permille, converted at the edge of this package. BatterySoC float64 diff --git a/go/internal/appproto/telemetry.go b/go/internal/appproto/telemetry.go index be008375c..aec2ad084 100644 --- a/go/internal/appproto/telemetry.go +++ b/go/internal/appproto/telemetry.go @@ -103,7 +103,9 @@ func fieldValues(snap Snapshot, modes []ModeInfo) map[string]int64 { fidKey(FidGridW): roundW(snap.GridW), fidKey(FidPvW): roundW(snap.PVW), fidKey(FidBatteryW): roundW(snap.BatteryW), - fidKey(FidLoadW): roundW(snap.LoadW), + } + if snap.LoadWKnown { + f[fidKey(FidLoadW)] = roundW(snap.LoadW) } if snap.BatterySoCKnown { f[fidKey(FidBatterySoc)] = units.PermilleFromFraction(snap.BatterySoC) diff --git a/go/internal/appproto/telemetry_test.go b/go/internal/appproto/telemetry_test.go index 67afd0e5e..9d98f36da 100644 --- a/go/internal/appproto/telemetry_test.go +++ b/go/internal/appproto/telemetry_test.go @@ -487,3 +487,84 @@ func TestEvFieldIsSentOnlyWhenAChargerExists(t *testing.T) { t.Fatalf("field 10 = %+v, want name ev_w and no source", def) } } + +func TestLoadFieldRequiresCoreQualification(t *testing.T) { + for _, tc := range []struct { + name string + snap Snapshot + known bool + want int64 + }{ + {name: "unknown zero"}, + {name: "unknown previous value", snap: Snapshot{LoadW: 3700}}, + {name: "known zero", snap: Snapshot{LoadWKnown: true}, known: true}, + {name: "known positive", snap: Snapshot{LoadW: 3700.6, LoadWKnown: true}, known: true, want: 3701}, + } { + t.Run(tc.name, func(t *testing.T) { + cloud, _ := LiveView(tc.snap, 60_000) + for name, fields := range map[string]map[string]int64{ + "session": fieldValues(tc.snap, nil), "cloud": cloud, + } { + got, present := fields[fidKey(FidLoadW)] + if present != tc.known || present && got != tc.want { + t.Errorf("%s load = %d, present=%v; want %d, present=%v", name, got, present, tc.want, tc.known) + } + } + }) + } +} + +func TestLoadUnknownAtSubscription(t *testing.T) { + h, box, rec, _ := newRig(t) + box.snap.LoadWKnown = false + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: 0, Max: ProtoMax}}) + deliver(t, h, MsgSub, nil, Sub{Bucket: 512, Hz: 1}) + snap := body[Snap](t, rec.only(t, MsgSnap)) + if _, present := snap.Fields[fidKey(FidLoadW)]; present { + t.Fatal("initial snapshot turned unknown load into a reading") + } + if _, present := snap.Dict[fidKey(FidLoadW)]; !present { + t.Fatal("unknown load removed the existing field 6 dictionary entry") + } +} + +func TestLoadLossAndReturnResyncTheStream(t *testing.T) { + h, box, rec, _ := newRig(t) + subscribe(t, h, rec) + + box.snap.LoadWKnown = false + if err := h.Tick(); err != nil { + t.Fatal(err) + } + snap := body[Snap](t, rec.only(t, MsgSnap)) + if _, present := snap.Fields[fidKey(FidLoadW)]; present { + t.Fatal("replacement snapshot retained the previous load reading") + } + if got := snap.Fields[fidKey(FidGridW)]; got != 1200 { + t.Fatalf("load loss changed the grid reading: %d", got) + } + if got := rec.types(); got != "[snap tick]" { + t.Fatalf("load loss sent %s; want one replacement snapshot and the lane 0 beat", got) + } + + rec.reset() + if err := h.Tick(); err != nil { + t.Fatal(err) + } + if got := rec.types(); got != "[tick]" { + t.Fatalf("unchanged unknown load sent %s; want only the lane 0 beat", got) + } + + box.snap.LoadW, box.snap.LoadWKnown = 0, true + rec.reset() + if err := h.Tick(); err != nil { + t.Fatal(err) + } + delta := body[Delta](t, rec.only(t, MsgDelta)) + if got, present := delta.Fields[fidKey(FidLoadW)]; !present || got != 0 { + t.Fatalf("returning known zero load = %d, present=%v", got, present) + } + if len(delta.Fields) != 1 || rec.has(MsgSnap) { + t.Fatal("returning load changed unrelated fields or forced another snapshot") + } +} From 052e6acc741920b1ff2b8542fcd557ec11872b41 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 18:32:30 +0200 Subject: [PATCH 2/6] fix(core): share one qualified live household balance --- .changeset/unified-live-household-load.md | 5 + go/cmd/ftw/app_link.go | 57 +++--- go/cmd/ftw/cloud_link.go | 8 +- go/cmd/ftw/live_load_test.go | 169 ++++++++++++++++++ go/cmd/ftw/main.go | 9 +- go/internal/api/api.go | 30 ---- go/internal/api/api_history_quality.go | 12 +- go/internal/api/api_test.go | 63 +++---- go/internal/telemetry/forecast.go | 9 + .../telemetry/live_measurement_test.go | 38 ++++ 10 files changed, 282 insertions(+), 118 deletions(-) create mode 100644 .changeset/unified-live-household-load.md create mode 100644 go/cmd/ftw/live_load_test.go create mode 100644 go/internal/telemetry/live_measurement_test.go diff --git a/.changeset/unified-live-household-load.md b/.changeset/unified-live-household-load.md new file mode 100644 index 000000000..2ac65a1fd --- /dev/null +++ b/.changeset/unified-live-household-load.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Use one qualified Core household power balance for the box page, phone sessions and FTW Cloud. Keep missing or invalid load unavailable instead of recalculating it from smoothed readings or showing zero. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index a3df6a366..87d6bc3a4 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -55,7 +55,15 @@ type appSite struct { // siteMeterStale is how long a site-meter reading stays current. It is // the watchdog timeout, so the app's freshness claim and the box's own // dispatch gate are answering from the same number. - siteMeterStale time.Duration + siteMeterStale time.Duration + measurementOptions func() telemetry.ForecastOptions +} + +func newAppSite(tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, + revision *control.Revision, siteMeterStale time.Duration, options func() telemetry.ForecastOptions, +) *appSite { + return &appSite{tel: tel, ctrl: ctrl, ctrlMu: ctrlMu, revision: revision, + started: processStarted, siteMeterStale: siteMeterStale, measurementOptions: options} } func (a *appSite) Snapshot() appproto.Snapshot { @@ -66,7 +74,14 @@ func (a *appSite) Snapshot() appproto.Snapshot { fuseA, fusePhases := a.ctrl.SiteFuseAmps, a.ctrl.SiteFusePhases a.ctrlMu.Unlock() - snap := appproto.Snapshot{Mode: mode, ControlRev: rev, FuseA: fuseA} + var opts telemetry.ForecastOptions + if a.measurementOptions != nil { + opts = a.measurementOptions() + } + 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} uptimeMs := time.Since(a.started).Milliseconds() seen := map[string]bool{} @@ -101,7 +116,6 @@ func (a *appSite) Snapshot() appproto.Snapshot { addSource(siteMeterDriver) if health := a.tel.DriverHealth(siteMeterDriver); health != nil && health.IsOnline() { if reading := a.tel.Get(siteMeterDriver, telemetry.DerMeter); reading != nil { - snap.GridW = reading.SmoothedW snap.GridPhaseA = gridPhaseAmps(reading, fusePhases) } } else { @@ -114,13 +128,6 @@ func (a *appSite) Snapshot() appproto.Snapshot { for _, reading := range a.tel.ReadingsByType(telemetry.DerPV) { addSource(reading.Driver) - if health := a.tel.DriverHealth(reading.Driver); health != nil && health.IsOnline() { - // Site convention: positive watts flow into the site, so PV is - // negative while generating. No sign conversion happens here — - // the driver boundary already did it, and doing it twice is how - // a solar panel starts consuming power. - snap.PVW += reading.SmoothedW - } } var socTotal float64 @@ -131,7 +138,6 @@ func (a *appSite) Snapshot() appproto.Snapshot { if health == nil || !health.IsOnline() { continue } - snap.BatteryW += reading.SmoothedW if reading.SoC != nil { socTotal += *reading.SoC socCount++ @@ -145,28 +151,15 @@ func (a *appSite) Snapshot() appproto.Snapshot { snap.BatterySoC = socTotal / float64(socCount) } - // EV chargers. Known means the site has one at all — an idle charger - // is a real 0 W reading, a site without one sends no field 10 and the - // app draws no EV node. Positive while charging: a charger consumes - // like any other load. The watts come from SumOnlineEVW so a charger - // that cannot take a command but is still drawing is counted here the - // same way the LAN dashboard counts it — IsOnline() would hide that - // draw in the house node. + // Keep charger presence and source metadata separate from qualified watts. + // An idle charger is a real zero; a missing configured flow still makes + // the household balance unavailable. for _, reading := range a.tel.ReadingsByType(telemetry.DerEV) { addSource(reading.Driver) snap.EVWKnown = true } - snap.EVW = a.tel.SumOnlineEVW() - - // grid = load + battery + pv + ev + v2x, all site-signed. Rearranged, - // not re-derived: a second formula here would be a second thing to - // keep in step with docs/site-convention.md. House load is never - // negative; metering noise that would push it below zero is the car - // or the battery, not the house consuming in reverse. - snap.LoadW = snap.GridW - snap.BatteryW - snap.PVW - - snap.EVW - a.tel.SumOnlineV2XW() - if snap.LoadW < 0 { - snap.LoadW = 0 + for _, source := range live.Sources { + addSource(source.Flow.Driver) } return snap @@ -670,6 +663,7 @@ func startAppLink( prefs *config.PlannerPrefs, revision *control.Revision, siteMeterStale time.Duration, + measurementOptions func() telemetry.ForecastOptions, gateway *lateAPI, cloud *cloudSession, agents *agentPorts, @@ -690,10 +684,7 @@ func startAppLink( return enroll, nil, false, nil } - site := &appSite{ - tel: tel, ctrl: ctrl, ctrlMu: ctrlMu, revision: revision, - started: processStarted, siteMeterStale: siteMeterStale, - } + site := newAppSite(tel, ctrl, ctrlMu, revision, siteMeterStale, measurementOptions) info := newAppBoxInfo(boxID, build) modes := &appModes{ctrl: ctrl, ctrlMu: ctrlMu, state: st, mpc: planner, prefs: prefs} plans := &appPlans{planner: planner, ctrl: ctrl, ctrlMu: ctrlMu} diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 0a45f4d26..b2836a102 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -388,16 +388,14 @@ func (n *cloudNotices) notify(kind string) { func startCloudLink( ctx context.Context, statePath, build string, st *state.Store, enroll *appenroll.Identity, tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, revision *control.Revision, - siteMeterStale time.Duration, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices, + siteMeterStale time.Duration, measurementOptions func() telemetry.ForecastOptions, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices, tunnel *appuplink.Tunnel, carsRT *cars.Runtime, webPush *notifications.WebPush, ) *cloudlink.Link { if st == nil || enroll == nil { return nil } - site := &cloudSite{api: gateway, cars: carsRT, site: &appSite{ - tel: tel, ctrl: ctrl, ctrlMu: ctrlMu, revision: revision, - started: processStarted, siteMeterStale: siteMeterStale, - }} + site := &cloudSite{api: gateway, cars: carsRT, + site: newAppSite(tel, ctrl, ctrlMu, revision, siteMeterStale, measurementOptions)} if priceSvc != nil && priceSvc.Zone != "" && priceSvc.Store != nil { site.prices = &appPrices{svc: priceSvc} } diff --git a/go/cmd/ftw/live_load_test.go b/go/cmd/ftw/live_load_test.go new file mode 100644 index 000000000..0da1579be --- /dev/null +++ b/go/cmd/ftw/live_load_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "encoding/json" + "math" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func liveLoadStatus(t *testing.T, tel *telemetry.Store, ctrl *control.State, options func() telemetry.ForecastOptions) map[string]any { + t.Helper() + srv := api.New(&api.Deps{Tel: tel, Ctrl: ctrl, CtrlMu: &sync.Mutex{}, + Cfg: &config.Config{}, CfgMu: &sync.RWMutex{}, CapMu: &sync.RWMutex{}, + HistoryMeasurementSources: options}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest("GET", "/api/status", nil)) + if rr.Code != 200 { + t.Fatal("status request failed") + } + var out map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatal("invalid status response") + } + return out +} + +func liveLoadData(t *testing.T, fields map[string]any) json.RawMessage { + t.Helper() + data, err := json.Marshal(fields) + if err != nil { + t.Fatal("invalid power evidence") + } + return data +} + +func TestLiveLoadAppAndLANShareSourcePowerAndValidity(t *testing.T) { + for _, scenario := range []string{"zero", "raw_not_smoothed", "site_side_ac", "missing_meter", "stale_power", "skew", "negative", "offline_ev", "idle_ev", "paused_ev", "faulted_ev"} { + t.Run(scenario, func(t *testing.T) { + tel := telemetry.NewStore() + for _, name := range []string{"meter", "solar", "battery", "charger"} { + tel.EnsureDriverHealth(name) + } + ctrl := &control.State{SiteMeterDriver: "meter", Mode: control.ModeSelfConsumption} + tel.Update("meter", telemetry.DerMeter, 1000, nil, nil) + want, valid := 1000.0, true + switch scenario { + case "zero": + tel.Update("meter", telemetry.DerMeter, 0, nil, nil) + want = 0 + case "raw_not_smoothed": + tel.Update("meter", telemetry.DerMeter, 5000, nil, nil) + want = 5000 + if tel.Get("meter", telemetry.DerMeter).SmoothedW == want { + t.Fatal("test did not distinguish smoothed power") + } + case "site_side_ac": + tel.Update("battery", telemetry.DerBattery, -2500, nil, liveLoadData(t, map[string]any{"control_power_w": -2000, "control_power_available": true})) + want = 3000 + case "missing_meter": + tel.Remove("meter") + valid = false + case "stale_power": + tel.Update("meter", telemetry.DerMeter, 1000, nil, liveLoadData(t, map[string]any{"control_power_observed_at": time.Now().Add(-2 * time.Minute).Format(time.RFC3339Nano)})) + valid = false + case "skew": + tel.Update("solar", telemetry.DerPV, -500, nil, liveLoadData(t, map[string]any{"control_power_observed_at": time.Now().Add(-45 * time.Second).Format(time.RFC3339Nano)})) + valid = false + case "negative": + tel.Update("battery", telemetry.DerBattery, 2000, nil, nil) + valid = false + case "offline_ev": + tel.Update("charger", telemetry.DerEV, 500, nil, nil) + tel.DriverHealthMut("charger").SetOffline() + valid = false + 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), + "control_power_available": true, "control_power_confirmed": true} + tel.Update("charger", telemetry.DerEV, 0, nil, liveLoadData(t, fields)) + case "faulted_ev": + tel.Update("charger", telemetry.DerEV, 500, nil, nil) + tel.SetDriverDeviceFault("charger", true, "command rejected") + want = 500 + } + site := &appSite{tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{}, revision: &control.Revision{}, started: time.Now(), siteMeterStale: time.Minute} + snap := site.Snapshot() + status := liveLoadStatus(t, tel, ctrl, nil) + if snap.LoadWKnown != valid || (status["load_w"] != nil) != valid { + t.Fatal("live adapters disagree on load validity") + } + if valid && (snap.LoadW != want || status["load_w"] != want) { + t.Fatal("live adapters did not use the same source watts") + } + cloud := (&cloudSite{site: site}).Live() + value, present := cloud.Fields[strconv.Itoa(int(appproto.FidLoadW))] + if present != valid || valid && value != int64(math.Round(want)) { + t.Fatal("Cloud changed load watts or validity") + } + if scenario == "site_side_ac" && (snap.BatteryW != -2000 || status["bat_w"] != float64(-2000)) { + t.Fatal("live battery power differs from the AC balance") + } + }) + } +} + +func TestLiveLoadOptionsFollowConfigurationForAppLANAndCloud(t *testing.T) { + tel := telemetry.NewStore() + tel.EnsureDriverHealth("meter") + tel.Update("meter", telemetry.DerMeter, 1000, nil, nil) + ctrl := &control.State{SiteMeterDriver: "meter"} + settings := &forecastSiteConfig{value: forecastSite{ + Options: telemetry.ForecastOptions{HouseholdInvalidReason: "unconfirmed_battery_identity"}, + HistoryOptions: telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: "battery", DerType: telemetry.DerBattery}}}, + }} + options := func() telemetry.ForecastOptions { return settings.Snapshot().HistoryOptions } + // Both link constructors use this constructor; Cloud uses the same appSite + // adapter for its readable live copy as the encrypted phone session. + site := newAppSite(tel, ctrl, &sync.Mutex{}, &control.Revision{}, time.Minute, options) + for _, batteryDeclared := range []bool{true, false, true} { + settings.mu.Lock() + if batteryDeclared { + settings.value.HistoryOptions.ExpectedFlows = []telemetry.ForecastFlow{{Driver: "battery", DerType: telemetry.DerBattery}} + } else { + settings.value.HistoryOptions.ExpectedFlows = nil + } + settings.mu.Unlock() + 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 { + t.Fatal("configured never-emitted flow differs between clients") + } + if known && (snap.LoadW != 1000 || status["load_w"] != float64(1000)) { + t.Fatal("learning-only identity gate affected live load") + } + if batteryDeclared { + found := false + for _, source := range snap.Sources { + found = found || source.ID == "battery" + } + if !found { + t.Fatal("never-emitted source missing from app metadata") + } + } + } +} + +func TestLiveLoadWithoutConfiguredMeterIsUnavailable(t *testing.T) { + tel := telemetry.NewStore() + ctrl := &control.State{} + site := newAppSite(tel, ctrl, &sync.Mutex{}, &control.Revision{}, time.Minute, nil) + status := liveLoadStatus(t, tel, ctrl, nil) + if _, present := status["load_w"]; !present || status["load_w"] != nil || status["grid_w"] != nil || site.Snapshot().LoadWKnown { + t.Fatal("missing configuration became a measured zero") + } + if _, present := (&cloudSite{site: site}).Live().Fields[strconv.Itoa(int(appproto.FidLoadW))]; present { + t.Fatal("Cloud invented load without a meter") + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 8a9c5d544..3816c244a 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2400,10 +2400,13 @@ func main() { // below once it exists, as FTW Cloud's grant notices are. cloudNotify := &cloudNotices{} agents := &agentPorts{logs: logRing, cfg: cfg, cfgMu: cfgMu, st: st, notices: cloudNotify, cars: carsRT} + liveMeasurementOptions := func() telemetry.ForecastOptions { + return forecastSettings.Snapshot().HistoryOptions + } appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, st, tel, mpcSvc, lpMgr, lpController, carsRT, priceSvc, ctrl, ctrlMu, plannerPrefs, - controlRev, appLinkWatchdog, appAPI, appCloud, agents, + controlRev, appLinkWatchdog, liveMeasurementOptions, appAPI, appCloud, agents, ) switch { case appLinkErr != nil: @@ -2424,7 +2427,7 @@ func main() { appTunnel = appUplink.Tunnel() } cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll, - tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel, carsRT, webPush) + tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, liveMeasurementOptions, priceSvc, appAPI, cloudNotify, appTunnel, carsRT, webPush) appCloud.bind(cloudLink) if cloudLink != nil { agents.home = cloudLink.Home @@ -2501,7 +2504,7 @@ func main() { SiteDispatchBlocked: func() string { return siteDispatchNow(tel, cfg, cfgMu, ctrl, ctrlMu, time.Now()).Reason }, - HistoryMeasurementSources: func() telemetry.ForecastOptions { return forecastSettings.Snapshot().HistoryOptions }, + HistoryMeasurementSources: liveMeasurementOptions, SiteMeasurementSources: func() telemetry.ForecastOptions { return forecastSettings.Snapshot().Options }, diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 0a435ca1b..555377741 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -810,11 +810,6 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { haveGrid = true } } - if !haveGrid && ctrl.SiteMeterDriver == "" { - // Preserve the historical "no configured site meter" behaviour for - // development setups: report zero rather than treating it as stale data. - haveGrid = true - } var pvW, batW float64 for _, r := range s.deps.Tel.ReadingsByType(telemetry.DerPV) { if !statusDriverOnline(s.deps.Tel, r.Driver) { @@ -829,21 +824,8 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { batW += r.SmoothedW } - // Load = house-only consumption in site convention (+ into site): - // meter = load + ev + v2x + battery + pv - // so load = grid - bat - pv - ev - v2x - // Subtracting vehicle charging/storage keeps the load signal (and - // the loadmodel trained on it) reflecting the house, not "house + car". evW := s.deps.Tel.SumOnlineEVW() v2xW := s.deps.Tel.SumOnlineV2XW() - loadW := 0.0 - if haveGrid { - rawLoad := gridW - batW - pvW - evW - v2xW - loadW = s.deps.Tel.UpdateLoad(rawLoad) - if loadW < 0 { - loadW = 0 - } - } // Weighted average SoC by capacity var totalCap, weightedSoC float64 @@ -1274,18 +1256,6 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { if left, limit, ok := ctrl.ImportHourStatus(ctrl.SiteFuseAmps * ctrl.SiteFuseVoltage * float64(ctrl.SiteFusePhases)); ok { resp["peak_import_hour_left_wh"], resp["peak_import_limit_w"] = left, limit } - // 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_history_quality.go b/go/internal/api/api_history_quality.go index 52b68d3be..a53a7c21c 100644 --- a/go/internal/api/api_history_quality.go +++ b/go/internal/api/api_history_quality.go @@ -13,13 +13,13 @@ func dailyEnergyCacheReady(dayStart, now time.Time) bool { } func (s *Server) qualifyStatusHistory(resp map[string]any, siteMeter string) { - if s.deps.HistoryMeasurementSources == nil { - return + var opts telemetry.ForecastOptions + if s.deps.HistoryMeasurementSources != nil { + opts = s.deps.HistoryMeasurementSources() } - opts := s.deps.HistoryMeasurementSources() - opts.UseWatchdogTimeout = true - opts.UseControlPower = true - r := s.deps.Tel.ForecastMeasurementNow(siteMeter, opts) + r := s.deps.Tel.LiveMeasurementNow(siteMeter, opts) + // Interval metadata remains separate from the current live balance. + opts.UseWatchdogTimeout, opts.UseControlPower = true, true resp["load_estimate"] = s.deps.Tel.HomeUseEstimate(time.Now(), siteMeter, opts) var missing state.HistoryMissing for kind, flag := range map[telemetry.DerType]state.HistoryMissing{telemetry.DerMeter: state.MissingGrid, telemetry.DerPV: state.MissingPV, telemetry.DerBattery: state.MissingBattery, telemetry.DerEV: state.MissingEV, telemetry.DerV2X: state.MissingV2X} { diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index a6b80c873..303875b54 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -170,7 +170,7 @@ func TestHandlePostConfigDoesNotPersistEVPasswordOnInvalidConfig(t *testing.T) { } } -func TestHandleStatusIgnoresOfflineDERInLiveBalance(t *testing.T) { +func TestHandleStatusKeepsOfflineFlowsUnavailable(t *testing.T) { tel := telemetry.NewStore() ctrl := &control.State{SiteMeterDriver: "site"} @@ -208,42 +208,25 @@ func TestHandleStatusIgnoresOfflineDERInLiveBalance(t *testing.T) { t.Fatalf("status = %d, body: %s", rr.Code, rr.Body.String()) } - var resp struct { - GridW float64 `json:"grid_w"` - PVW float64 `json:"pv_w"` - BatW float64 `json:"bat_w"` - EVW float64 `json:"ev_w"` - LoadW float64 `json:"load_w"` - BatSoC float64 `json:"bat_soc"` - Drivers map[string]map[string]any `json:"drivers"` - } + var resp map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } - - want := map[string]float64{ - "grid_w": 8000, - "pv_w": -2000, - "bat_w": -1000, - "ev_w": 1000, - "load_w": 10_000, - "bat_soc": 0.8, - } - got := map[string]float64{ - "grid_w": resp.GridW, - "pv_w": resp.PVW, - "bat_w": resp.BatW, - "ev_w": resp.EVW, - "load_w": resp.LoadW, - "bat_soc": resp.BatSoC, - } - for k, wantV := range want { - if got[k] != wantV { - t.Fatalf("%s = %v, want %v", k, got[k], wantV) + // Partial known flows do not prove the total PV, battery or household + // 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", + } { + if resp[field] != want { + t.Fatalf("%s differs from the qualified live value", field) } } - if resp.Drivers["pv-offline"]["status"] != "offline" || resp.Drivers["pv-offline"]["pv_w"] != -9000.0 { - t.Fatalf("offline driver details not preserved: %#v", resp.Drivers["pv-offline"]) + drivers := resp["drivers"].(map[string]any) + pv := drivers["pv-offline"].(map[string]any) + if pv["status"] != "offline" || pv["pv_w"] != nil { + t.Fatal("offline source lost its status or kept a live power value") } } @@ -285,11 +268,12 @@ func TestHandleStatusKeepsFaultedSiteMeterReading(t *testing.T) { if resp.GridW != 2400 { t.Fatalf("grid_w = %v, want fresh faulted-device meter reading 2400", resp.GridW) } - if resp.BatW != 0 { - t.Fatalf("bat_w = %v, want faulted actuator excluded", resp.BatW) + // A command fault does not erase fresh meter or battery observations. + if resp.BatW != -500 { + t.Fatalf("bat_w = %v, want the measured -500 W", resp.BatW) } - if resp.LoadW != 2400 { - t.Fatalf("load_w = %v, want 2400 from retained meter and excluded battery", resp.LoadW) + if resp.LoadW != 2900 { + t.Fatalf("load_w = %v, want 2900 from measured site and battery power", resp.LoadW) } if got := resp.Drivers["ferroamp"]["status"]; got != "fault" { t.Fatalf("driver status = %v, want fault", got) @@ -364,11 +348,8 @@ func TestHandleStatusOmitsWattsWhenSiteMeterIsOffline(t *testing.T) { 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"]) + if ferroamp["pv_w"] != nil || ferroamp["bat_w"] != nil { + t.Fatal("offline driver's last values became live observations") } } diff --git a/go/internal/telemetry/forecast.go b/go/internal/telemetry/forecast.go index a0081d1cb..69d6d2802 100644 --- a/go/internal/telemetry/forecast.go +++ b/go/internal/telemetry/forecast.go @@ -79,6 +79,15 @@ func (s *Store) ForecastMeasurementNow(siteMeter string, opts ForecastOptions) F return s.forecastMeasurementLocked(time.Now(), siteMeter, opts) } +// LiveMeasurementNow qualifies the current site balance for every live client. +// Display power uses source-side AC/control observations and each driver's +// watchdog cadence. Forecast training and stored history keep their own policy. +func (s *Store) LiveMeasurementNow(siteMeter string, opts ForecastOptions) ForecastReading { + opts.UseWatchdogTimeout = true + opts.UseControlPower = true + return s.ForecastMeasurementNow(siteMeter, opts) +} + func (s *Store) forecastMeasurementLocked(now time.Time, siteMeter string, opts ForecastOptions) ForecastReading { out := ForecastReading{At: now, Valid: true, PVValid: true, Known: map[DerType]bool{DerMeter: true, DerPV: true, DerBattery: true, DerEV: true, DerV2X: true}, Sources: map[string]ForecastSource{}} if s == nil { diff --git a/go/internal/telemetry/live_measurement_test.go b/go/internal/telemetry/live_measurement_test.go new file mode 100644 index 000000000..5de8602f5 --- /dev/null +++ b/go/internal/telemetry/live_measurement_test.go @@ -0,0 +1,38 @@ +package telemetry + +import ( + "encoding/json" + "testing" + "time" +) + +func TestLiveMeasurementUsesOneDisplayPolicyWithoutChangingHistory(t *testing.T) { + s := NewStore() + for _, driver := range []string{"meter", "battery"} { + s.EnsureDriverHealth(driver) + } + at := time.Now().Add(-3 * time.Second) + data, err := json.Marshal(map[string]any{"control_power_w": -2000, "control_power_available": true, "control_power_observed_at": at.Format(time.RFC3339Nano)}) + if err != nil { + t.Fatal("invalid power evidence") + } + s.Update("meter", DerMeter, 1000, nil, nil) + s.Update("battery", DerBattery, -2500, nil, data) + s.DriverHealthMut("battery").WatchdogTimeoutOverride = 10 * time.Second + opts := ForecastOptions{MaxAge: time.Second} + live := s.LiveMeasurementNow("meter", opts) + if !live.Valid || live.BatteryW != -2000 || live.HouseholdW != 3000 { + t.Fatal("live policy did not use measured AC power and the driver cadence") + } + if opts.UseControlPower || opts.UseWatchdogTimeout { + t.Fatal("live query mutated the caller's history policy") + } + raw := s.ForecastMeasurementNow("meter", ForecastOptions{}) + if !raw.Valid || raw.BatteryW != -2500 || raw.HouseholdW != 3500 { + t.Fatal("live query changed raw forecast/history power") + } + s.DriverHealthMut("battery").WatchdogTimeoutOverride = 0 + if s.LiveMeasurementNow("meter", opts).Valid { + t.Fatal("stale source clock ignored without its cadence override") + } +} From fbe1581d0fee05e56949ffcf63921e56bdbca135 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 18:35:47 +0200 Subject: [PATCH 3/6] refactor(telemetry): remove unused household load filter --- go/internal/telemetry/store.go | 21 ------------- go/internal/telemetry/telemetry_test.go | 41 ------------------------- 2 files changed, 62 deletions(-) diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 9fa3eba88..d25cebcbf 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -304,9 +304,6 @@ type Store struct { processNoise float64 measurementNoise float64 - // Separate slow filter for computed load (see UpdateLoad below). - loadFilter *KalmanFilter1D - // Per-cycle metric buffer. Drivers push via EmitMetric, the control loop // drains via FlushSamples once per tick. Decouples the hot path from // the (potentially blocking) DB writer. @@ -341,8 +338,6 @@ func NewStore() *Store { latestMetric: make(map[string]metricSnap), processNoise: 100, // W of expected change between samples measurementNoise: 50, // W of sensor noise - // Load: slow filter (process 20 — load changes slowly, measurement 500 — noisy) - loadFilter: NewKalman(20, 500), } } @@ -928,19 +923,3 @@ type WatchdogTransition struct { Name string Online bool } - -// UpdateLoad applies the slow load filter. load = grid - pv - bat is noisy -// because battery responds faster than the grid meter sees the change. This -// filter gives a stable house-load estimate. -// -// Negative rawLoad is physically impossible for household consumption and -// indicates a transient driver-startup or PV/meter sync-lag artifact. -// Clamping to zero prevents the Kalman filter from tracking garbage. -func (s *Store) UpdateLoad(rawLoad float64) float64 { - if rawLoad < 0 { - rawLoad = 0 - } - s.mu.Lock() - defer s.mu.Unlock() - return s.loadFilter.Update(rawLoad) -} diff --git a/go/internal/telemetry/telemetry_test.go b/go/internal/telemetry/telemetry_test.go index 4bc95b6b1..640d3d9f5 100644 --- a/go/internal/telemetry/telemetry_test.go +++ b/go/internal/telemetry/telemetry_test.go @@ -444,47 +444,6 @@ func TestStoreDropsCachedVehicleSoCWithoutFreshPredecessor(t *testing.T) { } } -// ---- Load filter ---- - -func TestLoadFilterClampsNegative(t *testing.T) { - // During driver startup or PV/meter sync lag, load = grid - pv - bat - // can go briefly negative. The filter must clamp to zero so the Kalman - // doesn't track garbage downward. - s := NewStore() - - // Seed with a realistic positive load so the filter has state. - for i := 0; i < 10; i++ { - s.UpdateLoad(1000) - } - - // Feed a burst of negative values. - var last float64 - for i := 0; i < 5; i++ { - last = s.UpdateLoad(-500) - } - if last < 0 { - t.Errorf("load filter should never return negative after clamping, got %f", last) - } - - // Single negative value should also be clamped. - out := s.UpdateLoad(-1) - if out < 0 { - t.Errorf("single negative load should be clamped, got %f", out) - } -} - -func TestLoadFilterSmoothsNoisy(t *testing.T) { - s := NewStore() - vals := []float64{1000, 2000, 500, 1500, 1200, 800, 1100, 1400} - var last float64 - for _, v := range vals { - last = s.UpdateLoad(v) - } - if last < 500 || last > 2000 { - t.Errorf("load filter should converge to middle, got %f", last) - } -} - // Happy path: two online EV drivers charging at 3.6 kW each → sum = 7200. func TestSumOnlineEVWSumsAllOnline(t *testing.T) { s := NewStore() From 9a37dbb8ed58ced11eb8321722c65dc05fe2cd5f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 18:41:46 +0200 Subject: [PATCH 4/6] fix(ha): publish the shared qualified Core load --- go/cmd/ftw/ha_bridge_owner_test.go | 2 +- go/cmd/ftw/main.go | 16 ++-- go/internal/ha/bridge.go | 20 ++++- go/internal/ha/live_load_test.go | 117 +++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 go/internal/ha/live_load_test.go diff --git a/go/cmd/ftw/ha_bridge_owner_test.go b/go/cmd/ftw/ha_bridge_owner_test.go index 7d0b785fb..770341239 100644 --- a/go/cmd/ftw/ha_bridge_owner_test.go +++ b/go/cmd/ftw/ha_bridge_owner_test.go @@ -87,7 +87,7 @@ func newRealHAOwner(modes chan<- string) *haBridgeOwner { return nil }} o := newHABridgeOwner(func(c *config.HomeAssistant, names []string) (haBridgeHandle, error) { - b, err := ha.Start(c, tel, ctrl, ctrlMu, names, cb, nil, nil) + b, err := ha.Start(c, tel, ctrl, ctrlMu, names, cb, nil, nil, nil) if err != nil { return nil, err } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 3816c244a..d1652ce78 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -827,6 +827,9 @@ func main() { return state.Device{} } forecastSettings := newForecastSiteConfig(st) + liveMeasurementOptions := func() telemetry.ForecastOptions { + return forecastSettings.Snapshot().HistoryOptions + } forecastSettings.identity = func(name string) (string, bool) { if id, ok := runningDeviceID(reg, name); ok { return id, true @@ -1037,7 +1040,7 @@ func main() { // the broker did not accept. Its start closure reads the registry and // control wiring only when it runs. haOwner := newHABridgeOwner(func(c *config.HomeAssistant, names []string) (haBridgeHandle, error) { - b, err := ha.Start(c, tel, ctrl, ctrlMu, names, haCallbacks(ctrl, ctrlMu, st, mpcSvc, plannerPrefs), mpcPlanSource(mpcSvc), haEnergySource(st)) + b, err := ha.Start(c, tel, ctrl, ctrlMu, names, haCallbacks(ctrl, ctrlMu, st, mpcSvc, plannerPrefs), mpcPlanSource(mpcSvc), haEnergySource(st), liveMeasurementOptions) if err != nil { return nil, err } @@ -2400,9 +2403,6 @@ func main() { // below once it exists, as FTW Cloud's grant notices are. cloudNotify := &cloudNotices{} agents := &agentPorts{logs: logRing, cfg: cfg, cfgMu: cfgMu, st: st, notices: cloudNotify, cars: carsRT} - liveMeasurementOptions := func() telemetry.ForecastOptions { - return forecastSettings.Snapshot().HistoryOptions - } appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, st, tel, mpcSvc, lpMgr, lpController, carsRT, priceSvc, ctrl, ctrlMu, plannerPrefs, @@ -3065,6 +3065,7 @@ func main() { batW := troubleshootingSumOnlineW(tel, telemetry.DerBattery) evW := tel.SumOnlineEVW() v2xW := tel.SumOnlineV2XW() + live := tel.LiveMeasurementNow(siteMeterDriver, liveMeasurementOptions()) attrs := []any{ "mode", mode, "plan_stale", planMissingNow, @@ -3075,14 +3076,17 @@ func main() { "bat_w", batW, "ev_w", evW, "v2x_w", v2xW, + "load_valid", live.Valid, "self_tune_active", selfTuneActive, "self_tune_driver", selfTuneName, "self_tune_command_w", selfTuneCmd, "targets", targets, "final_targets", finalTargets, } - if haveGrid { - attrs = append(attrs, "load_w", gridW-batW-pvW-evW-v2xW) + if live.Valid { + attrs = append(attrs, "load_w", live.HouseholdW) + } else { + attrs = append(attrs, "load_reason", live.Reason) } slog.Info("troubleshooting: dispatch decision", attrs...) } diff --git a/go/internal/ha/bridge.go b/go/internal/ha/bridge.go index 16bb3f623..1faa8e396 100644 --- a/go/internal/ha/bridge.go +++ b/go/internal/ha/bridge.go @@ -97,6 +97,9 @@ type Bridge struct { cb CommandCallbacks plan PlanSource // nil when MPC is not configured energy EnergySource // nil when state.Store is not available + // Set before the publish loop starts; the callback reads the current + // source topology shared by all live views. + measurementOptions func() telemetry.ForecastOptions topicPrefix string // stable wire ID, e.g. "forty-two-watts" discoPrefix string // e.g. "homeassistant" @@ -194,6 +197,7 @@ func Start( cb CommandCallbacks, plan PlanSource, energy EnergySource, + measurementOptions func() telemetry.ForecastOptions, ) (*Bridge, error) { b := &Bridge{ tel: tel, @@ -202,6 +206,7 @@ func Start( cb: cb, plan: plan, energy: energy, + measurementOptions: measurementOptions, topicPrefix: "forty-two-watts", discoPrefix: "homeassistant", deviceID: "forty_two_watts", @@ -933,17 +938,24 @@ func (b *Bridge) publishState() { } evW := b.tel.SumOnlineEVW() v2xW := b.tel.SumOnlineV2XW() - loadW := gridW - batW - pvW - evW - v2xW - if loadW < 0 { - loadW = 0 + options := telemetry.ForecastOptions{} + if b.measurementOptions != nil { + options = b.measurementOptions() } + live := b.tel.LiveMeasurementNow(siteMeter, options) b.publishValue("grid_w", gridW) b.publishValue("pv_w", pvW) b.publishValue("bat_w", batW) b.publishValue("ev_w", evW) b.publishValue("v2x_w", v2xW) - b.publishValue("load_w", loadW) + if live.Valid { + b.publishValue("load_w", live.HouseholdW) + } else { + // HA's numeric MQTT sensors ignore an empty payload. The literal + // None replaces the previous value with unknown. + b.publishString("load_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 new file mode 100644 index 000000000..e34687aeb --- /dev/null +++ b/go/internal/ha/live_load_test.go @@ -0,0 +1,117 @@ +package ha + +import ( + "encoding/json" + "sync" + "testing" + "time" + + paho "github.com/eclipse/paho.mqtt.golang" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +type statePublishClient struct { + paho.Client + mu sync.Mutex + payloads map[string][]string +} + +type completedPublish struct{ paho.Token } + +func (completedPublish) WaitTimeout(time.Duration) bool { return true } + +func (c *statePublishClient) Publish(topic string, _ byte, _ bool, payload any) paho.Token { + c.mu.Lock() + defer c.mu.Unlock() + c.payloads[topic] = append(c.payloads[topic], string(payload.([]byte))) + return completedPublish{} +} + +func (c *statePublishClient) last(t *testing.T, topic string) string { + t.Helper() + c.mu.Lock() + defer c.mu.Unlock() + values := c.payloads[topic] + if len(values) == 0 { + t.Fatal("sensor state was not published") + } + return values[len(values)-1] +} + +func liveLoadBridge(options func() telemetry.ForecastOptions) (*Bridge, *statePublishClient) { + client := &statePublishClient{payloads: map[string][]string{}} + return &Bridge{ + tel: telemetry.NewStore(), ctrl: &control.State{SiteMeterDriver: "meter"}, ctrlMu: &sync.Mutex{}, + client: client, topicPrefix: "ftw", measurementOptions: options, + }, client +} + +func TestLoadPublishUsesCoreRawMeasurement(t *testing.T) { + b, client := liveLoadBridge(nil) + b.tel.EnsureDriverHealth("meter") + b.tel.Update("meter", telemetry.DerMeter, 1000, nil, nil) + b.tel.Update("meter", telemetry.DerMeter, 5000, nil, nil) + if r := b.tel.Get("meter", telemetry.DerMeter); r == nil || r.RawW == r.SmoothedW { + t.Fatal("test requires distinct raw and smoothed readings") + } + b.publishState() + if got := client.last(t, b.stateTopic("load_w")); got != "5000.00" { + t.Fatal("HA load did not use the Core live measurement") + } +} + +func TestLoadPublishUsesCoreControlPowerPolicy(t *testing.T) { + b, client := liveLoadBridge(func() telemetry.ForecastOptions { + return telemetry.ForecastOptions{MaxAge: time.Second} + }) + for _, driver := range []string{"meter", "battery"} { + b.tel.EnsureDriverHealth(driver) + } + at := time.Now().Add(-3 * time.Second) + data, err := json.Marshal(map[string]any{ + "control_power_w": -2000, "control_power_available": true, + "control_power_observed_at": at.Format(time.RFC3339Nano), + }) + if err != nil { + t.Fatal("invalid test power evidence") + } + b.tel.Update("meter", telemetry.DerMeter, 1000, nil, nil) + b.tel.Update("battery", telemetry.DerBattery, -2500, nil, data) + b.tel.DriverHealthMut("battery").WatchdogTimeoutOverride = 10 * time.Second + b.publishState() + if got := client.last(t, b.stateTopic("load_w")); got != "3000.00" { + t.Fatal("HA load did not use Core AC power and driver cadence") + } +} + +func TestLoadPublishClearsUnknownAndRecovers(t *testing.T) { + options := telemetry.ForecastOptions{} + b, client := liveLoadBridge(func() telemetry.ForecastOptions { return options }) + b.tel.EnsureDriverHealth("meter") + b.tel.Update("meter", telemetry.DerMeter, 1000, nil, nil) + check := func(want string) { + t.Helper() + b.publishState() + if got := client.last(t, b.stateTopic("load_w")); got != want { + t.Fatal("HA load state did not follow Core qualification") + } + } + check("1000.00") + + options.ExpectedFlows = []telemetry.ForecastFlow{{Driver: "battery", DerType: telemetry.DerBattery}} + check("None") // HA ignores an empty numeric payload; None clears it to unknown. + check("None") + b.tel.EnsureDriverHealth("battery") + b.tel.Update("battery", telemetry.DerBattery, -200, nil, nil) + check("1200.00") + + b.tel.Update("meter", telemetry.DerMeter, 0, nil, nil) + b.tel.Update("battery", telemetry.DerBattery, 0, nil, nil) + check("0.00") + + options.HouseholdInvalidReason = "unresolved_topology" + check("None") + options.HouseholdInvalidReason = "" + check("0.00") +} From dbf32fabb5ef38ee82fb2337db8d3323d0074035 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 18:43:41 +0200 Subject: [PATCH 5/6] fix(support): report the qualified Core live balance --- go/internal/api/api_support_live_load_test.go | 161 ++++++++++++++++++ go/internal/api/api_support_report.go | 86 +++++----- go/internal/api/api_support_report_test.go | 21 ++- 3 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 go/internal/api/api_support_live_load_test.go diff --git a/go/internal/api/api_support_live_load_test.go b/go/internal/api/api_support_live_load_test.go new file mode 100644 index 000000000..1a08d7845 --- /dev/null +++ b/go/internal/api/api_support_live_load_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func supportLoadStatus(t *testing.T, s *Server) map[string]any { + t.Helper() + s.deps.Cfg, s.deps.CfgMu, s.deps.CapMu = &config.Config{}, &sync.RWMutex{}, &sync.RWMutex{} + rec := httptest.NewRecorder() + s.ServeHTTP(rec, httptest.NewRequest("GET", "/api/status", nil)) + if rec.Code != 200 { + t.Fatal("status request failed") + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal("invalid status response") + } + return out +} + +func TestSupportLiveLoadMatchesLANSourcePowerAndKnownZero(t *testing.T) { + for _, scenario := range []string{"raw_and_ac", "known_zero"} { + t.Run(scenario, func(t *testing.T) { + s, ctrl, tel := reportTestServer(t) + want := 0.0 + if scenario == "raw_and_ac" { + tel.Update("meter", telemetry.DerMeter, 5000, nil, nil) + tel.EnsureDriverHealth("battery") + tel.Update("battery", telemetry.DerBattery, -2500, nil, json.RawMessage(`{"control_power_w":-2000,"control_power_available":true}`)) + want = 7000 + } else { + tel.Update("meter", telemetry.DerMeter, 0, nil, nil) + } + status := supportLoadStatus(t, s) + snap := s.liveNow(*ctrl, time.Now()) + if status["load_w"] != want || snap.LoadW != want { + t.Fatal("support and LAN disagree on qualified household watts") + } + var b strings.Builder + writeRightNow(&b, *ctrl, snap, nil, nil, control.SlotEnergySnapshot{}, time.Now()) + if !strings.Contains(b.String(), "| House load | "+fmtReportW(want)+" |") { + t.Fatal("known load did not render as a number") + } + if !snap.HaveLoad { + t.Fatal("known zero lost load validity") + } + if scenario == "known_zero" { + snap.PredictedLd = 5000 + found := false + for _, f := range s.collectFindings(*ctrl, snap, nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) { + found = found || strings.Contains(f.Title, "load forecast") + } + if !found { + t.Fatal("a real forecast miss against known zero was hidden") + } + } + }) + } +} + +func TestSupportUnknownLoadRendersUnavailableWithoutForecastClaims(t *testing.T) { + for _, scenario := range []string{"negative_balance", "declared_never_emitted", "stale_power"} { + t.Run(scenario, func(t *testing.T) { + s, ctrl, tel := reportTestServer(t) + switch scenario { + case "negative_balance": + tel.EnsureDriverHealth("battery") + tel.Update("battery", telemetry.DerBattery, 2000, nil, nil) + case "declared_never_emitted": + s.deps.HistoryMeasurementSources = func() telemetry.ForecastOptions { + return telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: "battery", DerType: telemetry.DerBattery}}} + } + case "stale_power": + data, err := json.Marshal(map[string]any{"control_power_observed_at": time.Now().Add(-2 * time.Minute).Format(time.RFC3339Nano)}) + if err != nil { + t.Fatal("invalid source time") + } + tel.Update("meter", telemetry.DerMeter, 500, nil, data) + } + if supportLoadStatus(t, s)["load_w"] != nil { + t.Fatal("fixture did not make LAN household power unavailable") + } + snap := s.liveNow(*ctrl, time.Now()) + snap.PredictedLd = 5000 + active := &mpc.Action{LoadW: 5000, BatteryW: snap.BatW} + var b strings.Builder + writeRightNow(&b, *ctrl, snap, nil, nil, control.SlotEnergySnapshot{}, time.Now()) + writeForecastSection(&b, s, snap, active, time.Now()) + out := b.String() + if !strings.Contains(out, "| House load | **unavailable** |") || !strings.Contains(out, "| Load the active slot assumed | 5.00 kW | **unavailable** |") { + t.Fatal("unknown household load rendered as a watt value") + } + for _, f := range s.collectFindings(*ctrl, snap, nil, active, nil, nil, control.SlotEnergySnapshot{}, time.Now()) { + if strings.Contains(f.Title, "load forecast") || strings.Contains(f.Title, "slot assumed a different house") { + t.Fatal("unknown load caused a forecast or slot claim") + } + } + }) + } +} + +func TestSupportMissingFlowsDoNotCreatePowerMismatchClaims(t *testing.T) { + s, ctrl, _ := reportTestServer(t) + s.deps.HistoryMeasurementSources = func() telemetry.ForecastOptions { + return telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{ + {Driver: "solar", DerType: telemetry.DerPV}, {Driver: "battery", DerType: telemetry.DerBattery}, + }} + } + snap := s.liveNow(*ctrl, time.Now()) + if snap.HaveLoad || snap.HavePV || snap.HaveBattery { + t.Fatal("never-emitted flow became known") + } + snap.PredictedLd = 5000 + active := &mpc.Action{LoadW: 5000, PVW: -3000, BatteryW: 3000} + var b strings.Builder + writeRightNow(&b, *ctrl, snap, active, nil, control.SlotEnergySnapshot{}, time.Now()) + writeForecastSection(&b, s, snap, active, time.Now()) + out := b.String() + if !strings.Contains(out, "| Solar | **unavailable** |") || !strings.Contains(out, "| Battery | **unavailable** |") || strings.Contains(out, "away from that intent") { + t.Fatal("missing measurements became zero or a plan delivery claim") + } + for _, f := range s.collectFindings(*ctrl, snap, nil, active, nil, nil, control.SlotEnergySnapshot{}, time.Now()) { + if strings.Contains(f.Title, "far from reality") || strings.Contains(f.Title, "slot assumed a different house") || strings.Contains(f.Title, "battery is not following") { + t.Fatal("missing measurements caused a forecast or delivery claim") + } + } +} + +func TestSupportUnknownLoadNeverBlamesEitherForecast(t *testing.T) { + for _, activeOnly := range []bool{false, true} { + t.Run(map[bool]string{false: "model", true: "active_slot"}[activeOnly], func(t *testing.T) { + s, ctrl, _ := reportTestServer(t) + s.deps.HistoryMeasurementSources = func() telemetry.ForecastOptions { + return telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: "battery", DerType: telemetry.DerBattery}}} + } + snap := s.liveNow(*ctrl, time.Now()) + snap.PredictedLd = 5000 + var active *mpc.Action + if activeOnly { + snap.PredictedLd = snap.LoadW + active = &mpc.Action{LoadW: 5000} + } + for _, f := range s.collectFindings(*ctrl, snap, nil, active, nil, nil, control.SlotEnergySnapshot{}, time.Now()) { + if strings.Contains(f.Title, "load forecast") || strings.Contains(f.Title, "slot assumed a different house") { + t.Fatal("unknown load caused a forecast claim") + } + } + }) + } +} diff --git a/go/internal/api/api_support_report.go b/go/internal/api/api_support_report.go index f27963595..c78564349 100644 --- a/go/internal/api/api_support_report.go +++ b/go/internal/api/api_support_report.go @@ -143,19 +143,19 @@ func (s *Server) handleSupportReport(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) } -// liveSnapshot is the aggregated site state the report needs. It mirrors -// handleStatus's aggregation but deliberately skips Tel.UpdateLoad — that -// call mutates the shared smoothing filter, and generating a report must -// not perturb control inputs. The unsmoothed value is what we want anyway: -// a report should show the instant, not a filtered version of it. +// liveSnapshot adds forecast context to the same qualified live power that +// the LAN dashboard and app receive. Reading a report changes no telemetry. type liveSnapshot struct { GridW float64 HaveGrid bool PVW float64 + HavePV bool BatW float64 + HaveBattery bool EVW float64 V2XW float64 LoadW float64 + HaveLoad bool SoCPct float64 HaveSoC bool PredictedPV float64 @@ -163,25 +163,22 @@ type liveSnapshot struct { } func (s *Server) liveNow(ctrl control.State, now time.Time) liveSnapshot { - var snap liveSnapshot - if statusDriverTelemetryUsable(s.deps.Tel, ctrl.SiteMeterDriver) { - if r := s.deps.Tel.Get(ctrl.SiteMeterDriver, telemetry.DerMeter); r != nil { - snap.GridW = r.SmoothedW - snap.HaveGrid = true - } + var opts telemetry.ForecastOptions + if s.deps.HistoryMeasurementSources != nil { + opts = s.deps.HistoryMeasurementSources() } - for _, r := range s.deps.Tel.ReadingsByType(telemetry.DerPV) { - if h := s.deps.Tel.DriverHealth(r.Driver); h == nil || !h.IsOnline() { - continue - } - snap.PVW += r.SmoothedW + live := s.deps.Tel.LiveMeasurementNow(ctrl.SiteMeterDriver, opts) + snap := 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, } var socSum, socWeight float64 for _, r := range s.deps.Tel.ReadingsByType(telemetry.DerBattery) { if h := s.deps.Tel.DriverHealth(r.Driver); h == nil || !h.IsOnline() { continue } - snap.BatW += r.SmoothedW if r.SoC != nil { socSum += *r.SoC socWeight++ @@ -191,14 +188,6 @@ func (s *Server) liveNow(ctrl control.State, now time.Time) liveSnapshot { snap.SoCPct = socSum / socWeight * 100 snap.HaveSoC = true } - snap.EVW = s.deps.Tel.SumOnlineEVW() - snap.V2XW = s.deps.Tel.SumOnlineV2XW() - if snap.HaveGrid { - snap.LoadW = snap.GridW - snap.BatW - snap.PVW - snap.EVW - snap.V2XW - if snap.LoadW < 0 { - snap.LoadW = 0 - } - } if s.deps.PVModel != nil { snap.PredictedPV = -s.deps.PVModel.PredictNow() } @@ -326,9 +315,9 @@ func writeRightNow( } else { b.WriteString("| Grid | **no site meter reading** |\n") } - fmt.Fprintf(b, "| Solar | %s |\n", fmtReportW(snap.PVW)) - fmt.Fprintf(b, "| House load | %s |\n", fmtReportW(snap.LoadW)) - fmt.Fprintf(b, "| Battery | %s |\n", fmtReportW(snap.BatW)) + fmt.Fprintf(b, "| Solar | %s |\n", fmtReportLiveW(snap.PVW, snap.HavePV)) + 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)) } @@ -354,13 +343,13 @@ func writeRightNow( end.Format("15:04"), fmtReportW(activeSlot.BatteryW), activeSlot.Reason) - if delta := math.Abs(activeSlot.BatteryW - snap.BatW); delta > 500 { + if delta := math.Abs(activeSlot.BatteryW - snap.BatW); snap.HaveBattery && delta > 500 { fmt.Fprintf(b, "The battery is **%s away from that intent** "+ "(planned %s, actual %s). A gap this size is either a "+ "safety clamp, a device that cannot follow the command, or "+ "a plan replaced since the slot began — check the replan "+ "reason in `Plan`.\n\n", - fmtReportW(delta), fmtReportW(activeSlot.BatteryW), fmtReportW(snap.BatW)) + fmtReportW(delta), fmtReportW(activeSlot.BatteryW), fmtReportLiveW(snap.BatW, snap.HaveBattery)) } } else { b.WriteString("No plan slot covers this moment.\n\n") @@ -497,14 +486,14 @@ func writeForecastSection(b *strings.Builder, s *Server, snap liveSnapshot, acti b.WriteString("| Quantity | Forecast | Actual now |\n|---|---|---|\n") fmt.Fprintf(b, "| House load | %s | %s |\n", - fmtReportW(snap.PredictedLd), fmtReportW(snap.LoadW)) + fmtReportW(snap.PredictedLd), fmtReportLiveW(snap.LoadW, snap.HaveLoad)) fmt.Fprintf(b, "| Solar | %s | %s |\n", - fmtReportW(snap.PredictedPV), fmtReportW(snap.PVW)) + fmtReportW(snap.PredictedPV), fmtReportLiveW(snap.PVW, snap.HavePV)) if activeSlot != nil { fmt.Fprintf(b, "| Load the active slot assumed | %s | %s |\n", - fmtReportW(activeSlot.LoadW), fmtReportW(snap.LoadW)) + fmtReportW(activeSlot.LoadW), fmtReportLiveW(snap.LoadW, snap.HaveLoad)) fmt.Fprintf(b, "| Solar the active slot assumed | %s | %s |\n", - fmtReportW(activeSlot.PVW), fmtReportW(snap.PVW)) + fmtReportW(activeSlot.PVW), fmtReportLiveW(snap.PVW, snap.HavePV)) } b.WriteString("\n") @@ -722,6 +711,12 @@ func (s *Server) collectFindings( "guesswork until the meter reports again."}) } + 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 ctrl.PlanStale { out = append(out, finding{sevProblem, "The plan is stale", "The planner has not produced a usable plan, so control fell back " + @@ -757,34 +752,34 @@ func (s *Server) collectFindings( "so expect this to close on its own over the first weeks. A reset " + "would only start that clock again." } - if forecastMiss(snap.PredictedLd, snap.LoadW) { + if snap.HaveLoad && forecastMiss(snap.PredictedLd, snap.LoadW) { out = append(out, finding{forecastSeverity, "The load forecast is far from reality", fmt.Sprintf("The model predicts %s for right now; the house is "+ "drawing %s. The planner sized this slot against the forecast, "+ "so its charge and discharge decisions are built on the wrong "+ "house.%s", - fmtReportW(snap.PredictedLd), fmtReportW(snap.LoadW), learningNote)}) - } else if activeSlot != nil && forecastMiss(activeSlot.LoadW, snap.LoadW) { + fmtReportW(snap.PredictedLd), fmtReportLiveW(snap.LoadW, snap.HaveLoad), learningNote)}) + } else if snap.HaveLoad && activeSlot != nil && forecastMiss(activeSlot.LoadW, snap.LoadW) { out = append(out, finding{forecastSeverity, "The active slot assumed a different house", fmt.Sprintf("This slot was planned for a load of %s; actual load is "+ "%s. Expect the live setpoint to diverge from the plan, and "+ "expect frequent replans.%s", - fmtReportW(activeSlot.LoadW), fmtReportW(snap.LoadW), learningNote)}) + fmtReportW(activeSlot.LoadW), fmtReportLiveW(snap.LoadW, snap.HaveLoad), learningNote)}) } - if activeSlot != nil && forecastMiss(math.Abs(activeSlot.PVW), math.Abs(snap.PVW)) { + if snap.HavePV && activeSlot != nil && forecastMiss(math.Abs(activeSlot.PVW), math.Abs(snap.PVW)) { out = append(out, finding{sevWarning, "The solar forecast is far from reality", fmt.Sprintf("This slot was planned for %s of solar; actual is %s.", - fmtReportW(activeSlot.PVW), fmtReportW(snap.PVW))}) + fmtReportW(activeSlot.PVW), fmtReportLiveW(snap.PVW, snap.HavePV))}) } - if activeSlot != nil { + if snap.HaveBattery && activeSlot != nil { if delta := math.Abs(activeSlot.BatteryW - snap.BatW); delta > 1000 { out = append(out, finding{sevWarning, "The battery is not following the plan", fmt.Sprintf("The slot intends %s, the battery is doing %s. "+ "Either safety clamped it, the device cannot deliver, or "+ "a replan changed the plan after this slot started.", - fmtReportW(activeSlot.BatteryW), fmtReportW(snap.BatW))}) + fmtReportW(activeSlot.BatteryW), fmtReportLiveW(snap.BatW, snap.HaveBattery))}) } } @@ -986,6 +981,13 @@ func isActiveAction(a *mpc.Action, now time.Time) bool { return ms >= start && ms < end } +func fmtReportLiveW(v float64, known bool) string { + if !known { + return "**unavailable**" + } + return fmtReportW(v) +} + func fmtReportW(v float64) string { if math.Abs(v) >= 1000 { return fmt.Sprintf("%.2f kW", v/1000) diff --git a/go/internal/api/api_support_report_test.go b/go/internal/api/api_support_report_test.go index ebe1bf4f9..3f77357b5 100644 --- a/go/internal/api/api_support_report_test.go +++ b/go/internal/api/api_support_report_test.go @@ -117,7 +117,7 @@ func TestPlanControlDecisionMismatchBecomesFinding(t *testing.T) { plan := &mpc.Plan{DecisionID: "00000000-0000-4000-8000-000000000202"} slot := control.SlotEnergySnapshot{DecisionID: "00000000-0000-4000-8000-000000000201"} findings := srv.collectFindings(*ctrl, - liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, + liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true, LoadW: 1000, PredictedLd: 1000}, plan, nil, nil, nil, slot, time.Now()) for _, got := range findings { @@ -139,6 +139,9 @@ func TestSupportReportFlagsLoadForecastMiss(t *testing.T) { now := time.Now() snap := liveSnapshot{ HaveGrid: true, + HaveLoad: true, + HavePV: true, + HaveBattery: true, LoadW: 7900, PredictedLd: 383, } @@ -244,7 +247,7 @@ func TestSupportReportMarksTheActiveSlot(t *testing.T) { // And the live section should state the active slot's intent in prose. var live strings.Builder - writeRightNow(&live, *ctrl, liveSnapshot{HaveGrid: true, BatW: -7500}, &plan.Actions[0], nil, control.SlotEnergySnapshot{}, now) + writeRightNow(&live, *ctrl, liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true, BatW: -7500}, &plan.Actions[0], nil, control.SlotEnergySnapshot{}, now) if !strings.Contains(live.String(), "-8.40 kW") { t.Errorf("active-slot intent missing from Right now:\n%s", live.String()) } @@ -261,7 +264,7 @@ func TestSupportReportFlagsFallbackSolver(t *testing.T) { }, } findings := srv.collectFindings(*ctrl, - liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, + liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true, LoadW: 1000, PredictedLd: 1000}, plan, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) found := false @@ -286,7 +289,7 @@ func TestSupportReportFlagsOfflineAndFaultedDevices(t *testing.T) { "faulting": {Name: "faulting", DeviceFault: true, DeviceFaultReason: "Fault mode"}, } findings := srv.collectFindings(*ctrl, - liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, + liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true, LoadW: 1000, PredictedLd: 1000}, nil, nil, nil, health, control.SlotEnergySnapshot{}, time.Now()) var sawOffline, sawFault bool @@ -403,7 +406,7 @@ func TestRepeatedWarningBecomesAFinding(t *testing.T) { srv := New(&Deps{Ctrl: st, CtrlMu: &sync.Mutex{}, Tel: tel, LogRing: ring}) findings := srv.collectFindings(*st, - liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, + liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true, LoadW: 1000, PredictedLd: 1000}, nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) var got *finding @@ -429,7 +432,7 @@ func TestRepeatedWarningBecomesAFinding(t *testing.T) { func TestDispatchTableDisclaimsSiteLevelClamps(t *testing.T) { st := control.NewState(0, 50, "meter") var b strings.Builder - writeRightNow(&b, *st, liveSnapshot{HaveGrid: true}, + writeRightNow(&b, *st, liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true}, nil, []control.DispatchTarget{{Driver: "ferroamp", TargetW: 6400, Clamped: false}}, control.SlotEnergySnapshot{}, time.Now()) out := b.String() @@ -455,7 +458,7 @@ func TestForecastMissIsANoteWhileTheModelIsStillLearning(t *testing.T) { srv := New(&Deps{Ctrl: st, CtrlMu: &sync.Mutex{}, Tel: tel, LoadModel: lm}) findings := srv.collectFindings(*st, - liveSnapshot{HaveGrid: true, LoadW: 3650, PredictedLd: 1470}, + liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true, LoadW: 3650, PredictedLd: 1470}, nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) var got *finding @@ -495,7 +498,7 @@ func TestSlotEnergyShortfallIsAFinding(t *testing.T) { SlotEnd: now.Add(7 * time.Minute), } findings := srv.collectFindings(*ctrl, - liveSnapshot{HaveGrid: true, LoadW: 700, PredictedLd: 700}, + liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true, LoadW: 700, PredictedLd: 700}, nil, nil, nil, nil, slot, now) var got *finding @@ -597,7 +600,7 @@ func TestSlotEnergyBooksAreInTheReport(t *testing.T) { _, ctrl, _ := reportTestServer(t) now := time.Now() var b strings.Builder - writeRightNow(&b, *ctrl, liveSnapshot{HaveGrid: true}, nil, nil, + writeRightNow(&b, *ctrl, liveSnapshot{HaveGrid: true, HaveLoad: true, HavePV: true, HaveBattery: true}, nil, nil, control.SlotEnergySnapshot{ HasSlot: true, PlannedWh: 1125, ActualWh: 560, EnergyPathWh: 545, PlannedSoFarWh: 600, From 4daab0799ead10d9abdbf4ab66bdbe7d476ca6bb Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 18:45:11 +0200 Subject: [PATCH 6/6] fix(web): render Core load validity without a second gate --- web/components/energy-flow-readings.js | 3 +-- web/energy-flow-readings.test.mjs | 10 ++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index 67c7653c2..d32ad6092 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -246,8 +246,7 @@ 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 && - (status?.history_quality == null || status.history_quality === "complete") ? rawLoad : null; + const powerBalanceW = rawLoad !== null && rawLoad >= 0 ? rawLoad : null; 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 80608c5a4..1195be768 100644 --- a/web/energy-flow-readings.test.mjs +++ b/web/energy-flow-readings.test.mjs @@ -63,8 +63,14 @@ it("distinguishes a real zero balance from missing, invalid or stale inputs", () assert.equal(result.load,null); assert.match(result.homeEstimateNote,/unavailable/); } - for (const quality of ["incomplete", "balance_mismatch"]) { - assert.equal(flowReadingsFromStatus({...base,history_quality:quality,load_w:1234}).load,null); +}); + +it("renders Core load validity without another history-quality decision", () => { + for (const quality of ["complete", "incomplete", "balance_mismatch"]) { + const status = {history_quality: quality, load_estimate: {watts: null}}; + assert.equal(flowReadingsFromStatus({...status, load_w: 1234}).load, 1.234); + assert.equal(flowReadingsFromStatus({...status, load_w: 0}).load, 0); + assert.equal(flowReadingsFromStatus({...status, load_w: null}).load, null); } });