From 0a6aa2f1cfa21a40cb497f771d1fcfc77b8526c8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 11:11:49 +0200 Subject: [PATCH] fix(live): one Home use; the page follows load_quality alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/status always sent load_estimate, an energy-averaged Home use, and the energy flow treated its presence as "estimated". Every box therefore showed "≈" on Home use and never showed "% self-powered now", even with every device read. The page now reads Core's load_quality only. HomeUseEstimate came in on 18 October as the live Home use and was left behind when #1768 made Home use the plain balance. It fed only load_estimate and a second Home Assistant sensor that has not shipped. Remove it, its observation buffers in the telemetry store and the HA sensor, so Core has one Home use value. Co-Authored-By: Claude Opus 5.5 --- .changeset/live-load-quality-flag.md | 7 + go/internal/api/api_history_quality.go | 3 - go/internal/api/api_home_estimate_test.go | 21 +- go/internal/ha/bridge.go | 11 +- go/internal/ha/live_load_test.go | 13 +- .../telemetry/home_cloud_timing_test.go | 78 ----- go/internal/telemetry/home_energy.go | 101 ------ go/internal/telemetry/home_energy_test.go | 275 --------------- go/internal/telemetry/home_estimate.go | 312 ------------------ go/internal/telemetry/home_estimate_test.go | 201 ----------- go/internal/telemetry/home_idle_test.go | 227 ------------- go/internal/telemetry/store.go | 9 - web/components/energy-flow-readings.js | 2 +- web/energy-flow-readings.test.mjs | 15 +- 14 files changed, 35 insertions(+), 1240 deletions(-) create mode 100644 .changeset/live-load-quality-flag.md delete mode 100644 go/internal/telemetry/home_cloud_timing_test.go delete mode 100644 go/internal/telemetry/home_energy.go delete mode 100644 go/internal/telemetry/home_energy_test.go delete mode 100644 go/internal/telemetry/home_estimate.go delete mode 100644 go/internal/telemetry/home_estimate_test.go delete mode 100644 go/internal/telemetry/home_idle_test.go diff --git a/.changeset/live-load-quality-flag.md b/.changeset/live-load-quality-flag.md new file mode 100644 index 000000000..85380b081 --- /dev/null +++ b/.changeset/live-load-quality-flag.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Home use on the energy flow no longer shows "≈" when every device is read, and "% self-powered now" is back. The page marked Home use as an estimate whenever /api/status carried a second, energy-averaged value, which it always did. It now follows Core's `load_quality` alone, and /api/status no longer sends `load_estimate`. + +Home use has one value. 0.154.3 was never published as a beta; its change also ships here: Home use is the site meter minus every device that is read, shown when one device is offline (marked "≈"), and blank only when the site meter is missing or stale. The Home Assistant sensor "Load Power (energy average)" announced for 0.154.3 is removed before release; "Load Power" stays. diff --git a/go/internal/api/api_history_quality.go b/go/internal/api/api_history_quality.go index 24d7a6110..063299a51 100644 --- a/go/internal/api/api_history_quality.go +++ b/go/internal/api/api_history_quality.go @@ -18,9 +18,6 @@ func (s *Server) qualifyStatusHistory(resp map[string]any, siteMeter string) { opts = s.deps.HistoryMeasurementSources() } 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} { if !r.Known[kind] { diff --git a/go/internal/api/api_home_estimate_test.go b/go/internal/api/api_home_estimate_test.go index b50302d2e..6984af761 100644 --- a/go/internal/api/api_home_estimate_test.go +++ b/go/internal/api/api_home_estimate_test.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "math" "testing" "time" @@ -10,7 +9,9 @@ import ( "github.com/srcfl/ftw/go/internal/telemetry" ) -func TestStatusHomeEstimateIsSeparateFromInstantaneousPower(t *testing.T) { +// Status sends one Home use: the live balance and its quality. A second, +// energy-averaged number made every client show the live value as an estimate. +func TestStatusSendsOneHomeUse(t *testing.T) { tel := telemetry.NewStore() tel.EnsureDriverHealth("site") base := time.Now().Add(-20 * time.Second) @@ -22,17 +23,16 @@ func TestStatusHomeEstimateIsSeparateFromInstantaneousPower(t *testing.T) { resp := map[string]any{} for i := 0; i < 3; i++ { srv.qualifyStatusHistory(resp, "site") - e, ok := resp["load_estimate"].(telemetry.HomeEstimate) - if !ok || e.Watts == nil || math.Abs(*e.Watts-1500) > 1e-6 || e.AgeMS < 15000 || e.AgeMS > 20000 || e.Method != "reported_energy_balance_v3" || e.TimingBasis != "source" || e.EnergyBasis != "power_integral" || e.EnergyWh == nil || e.EndAgeMS < 10000 { - t.Fatal(e) + if _, ok := resp["load_estimate"]; ok { + t.Fatal("status sent a second Home use") } - if resp["load_w"] != float64(2000) || resp["grid_w"] != float64(2000) || resp["history_quality"] != "complete" { + if resp["load_w"] != float64(2000) || resp["load_quality"] != telemetry.LoadMeasured || resp["grid_w"] != float64(2000) || resp["history_quality"] != "complete" { t.Fatal(resp) } } tel.DriverHealthMut("site").SetOffline() srv.qualifyStatusHistory(resp, "site") - if resp["load_w"] != nil || resp["load_estimate"].(telemetry.HomeEstimate).Watts != nil { + if resp["load_w"] != nil || resp["load_quality"] != telemetry.LoadUnknown { t.Fatal(resp) } } @@ -60,14 +60,11 @@ func TestStatusKeepsPowerBalanceWhenSessionEnergyCannotFormAnInterval(t *testing if resp["load_w"] != float64(1234) || resp["history_quality"] != "complete" { t.Fatal("valid power balance disappeared") } - if e := resp["load_estimate"].(telemetry.HomeEstimate); e.Watts != nil || e.Reason != "incomplete_source_window" { - t.Fatal("old session was presented as an energy interval") - } tel.DriverHealthMut("charger").SetOffline() srv.qualifyStatusHistory(resp, "site") // 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") + if resp["load_w"] != float64(1234) || resp["load_quality"] != "estimated" { + t.Fatal("offline charger blanked Home use") } tel.RecordDriverSuccess("charger") tel.Update("charger", telemetry.DerEV, 6000, nil, nil) diff --git a/go/internal/ha/bridge.go b/go/internal/ha/bridge.go index f85231428..9aae52446 100644 --- a/go/internal/ha/bridge.go +++ b/go/internal/ha/bridge.go @@ -492,10 +492,8 @@ 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). + // Live balance as the dashboard shows it. {"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")}, @@ -959,13 +957,6 @@ 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 a0232b2a6..a0c41e42a 100644 --- a/go/internal/ha/live_load_test.go +++ b/go/internal/ha/live_load_test.go @@ -121,9 +121,9 @@ func TestLoadPublishClearsUnknownAndRecovers(t *testing.T) { 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) { +// HA gets one Home use: the live balance as the dashboard shows it, +// negative included. +func TestLoadPublishesOneHomeUse(t *testing.T) { b, client := liveLoadBridge(nil) for _, d := range []string{"meter", "battery"} { b.tel.EnsureDriverHealth(d) @@ -134,20 +134,17 @@ func TestLoadPublishesDisplayAndEnergySeries(t *testing.T) { 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")) + energy = energy || strings.Contains(topic, "load_energy_power/config") } } client.mu.Unlock() - if !display || !energy { + if !display || energy { t.Fatalf("discovery: display=%t energy=%t", display, energy) } } diff --git a/go/internal/telemetry/home_cloud_timing_test.go b/go/internal/telemetry/home_cloud_timing_test.go deleted file mode 100644 index c7a0f7aa5..000000000 --- a/go/internal/telemetry/home_cloud_timing_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package telemetry - -import ( - "math" - "testing" - "time" -) - -func homeReceiptSample(s *Store, name string, kind DerType, watts float64, received time.Time) { - s.mu.Lock() - defer s.mu.Unlock() - s.recordHomeObservation(name, kind, watts, nil, received) - s.readings[key(name, kind)] = &DerReading{Driver: name, DerType: kind, RawW: watts, UpdatedAt: received} - s.health[name] = &DriverHealth{Name: name} -} - -func TestHomeEstimateCloudPollDoesNotAdvancePowerTime(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 120; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - power := 6000.0 - if sec >= 10 && sec <= 20 { - power += float64(sec-10) * 76.8 - } else if sec > 20 { - power = 6768 - } - homeReceiptSample(s, "site", DerMeter, 1000+power, at) - if sec == 10 { - homeSample(s, "cloud", DerEV, 6000, at, at, true) - } - if sec >= 20 { - homeSample(s, "cloud", DerEV, 6768, base.Add(20*time.Second), at, true) - } - } - got := s.HomeUseEstimate(base.Add(120*time.Second), "site", ForecastOptions{}) - if got.Watts == nil || got.ToMS != base.Add(20*time.Second).UnixMilli() || got.AgeMS != 105000 || got.TimingBasis != "mixed" { - t.Fatalf("fresh HTTP replies manufactured fresh power evidence: %+v", got) - } -} - -func TestHomeEstimateOneMinuteCloudDelayUsesPastWindow(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 150; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - ev := func(sec int) float64 { - if sec >= 30 && sec < 70 { - return 9714 - } - return 0 - } - homeReceiptSample(s, "site", DerMeter, 1000+ev(sec), at) - if sec >= 60 { - homeSample(s, "cloud", DerEV, ev(sec-60), at.Add(-time.Minute), at, true) - } - if sec < 70 { - continue - } - got := s.HomeUseEstimate(at, "site", ForecastOptions{}) - if got.Watts == nil || got.ToMS != at.Add(-time.Minute).UnixMilli() || got.AgeMS != 65000 || math.Abs(*got.Watts-1000) > 1e-8 || got.SourceAgeMS != 60000 { - t.Fatalf("second %d: cannot compare the reported past window: %+v", sec, got) - } - } -} - -func TestHomeEstimateRejectsChargerWithoutSourceTime(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 20; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 8000, at) - homeReceiptSample(s, "cloud", DerEV, 7000, at) - } - if got := s.HomeUseEstimate(base.Add(20*time.Second), "site", ForecastOptions{}); got.Watts != nil { - t.Fatalf("a charger receipt time was presented as measurement alignment: %+v", got) - } -} diff --git a/go/internal/telemetry/home_energy.go b/go/internal/telemetry/home_energy.go deleted file mode 100644 index 9bab53d3f..000000000 --- a/go/internal/telemetry/home_energy.go +++ /dev/null @@ -1,101 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "time" -) - -type homeEnergyPoint struct { - At time.Time - Wh float64 -} - -type homeEnergySeries struct { - Session string - Points []homeEnergyPoint -} - -// Easee already supplies these three fields together. A counter without its -// observation time cannot distinguish a fresh read from a cached slow register. -// In particular, do not receipt-stamp local counters or mix battery DC energy -// with the AC power used in the site balance. Those flows use sampled power. -func (s *Store) recordHomeEnergy(driver string, kind DerType, data json.RawMessage, now time.Time) { - if kind != DerEV { - return - } - k := key(driver, kind) - var d struct { - Wh *float64 `json:"session_wh"` - At string `json:"energy_observed_at"` - Session string `json:"session_id"` - } - err := json.Unmarshal(data, &d) - series := s.homeEnergy[k] - if series == nil { - if d.At == "" { - return - } - if s.homeEnergy == nil { - s.homeEnergy = map[string]*homeEnergySeries{} - } - series = &homeEnergySeries{} - s.homeEnergy[k] = series - } - at, timeErr := time.Parse(time.RFC3339Nano, d.At) - if err != nil || timeErr != nil || at.After(now) || d.Wh == nil || !finite(*d.Wh) || *d.Wh < 0 || d.Session == "" { - series.Points = nil - return - } - if !s.health[driver].TelemetryLive() || series.Session != d.Session { - series.Points = nil - } - series.Session = d.Session - p := homeEnergyPoint{At: at, Wh: *d.Wh} - points := series.Points - if len(points) > 0 { - last := points[len(points)-1] - if at.Before(last.At) || (at.Equal(last.At) && p.Wh != last.Wh) { - series.Points = nil - return - } - if at.Equal(last.At) { - return // A poll of the same observation adds neither energy nor time. - } - if p.Wh < last.Wh || at.Sub(last.At) > homeMaxWindow { - points = nil // Reset, missed session boundary, or unsupported long gap. - } - } - points = append(points, p) - cutoff := now.Add(-homeLookback - homeMaxWindow) - for len(points) > 1 && points[1].At.Before(cutoff) { - points = points[1:] - } - if len(points) > 1024 { - points = points[len(points)-1024:] - } - series.Points = points -} - -// Counter differences give energy over their own interval. At an interior -// boundary, linear cumulative-energy interpolation is an estimate, not evidence -// of constant instantaneous power. Never extend beyond the reported endpoints. -func homeCounterEnergy(points []homeEnergyPoint, from, to time.Time) (float64, bool) { - energyAt := func(at time.Time) (float64, bool) { - for i, p := range points { - if p.At.Equal(at) { - return p.Wh, true - } - if i+1 < len(points) { - next := points[i+1] - if p.At.Before(at) && next.At.After(at) && next.At.Sub(p.At) <= homeMaxWindow { - f := at.Sub(p.At).Seconds() / next.At.Sub(p.At).Seconds() - return p.Wh + (next.Wh-p.Wh)*f, true - } - } - } - return 0, false - } - a, okA := energyAt(from) - b, okB := energyAt(to) - return b - a, okA && okB && to.After(from) && finite(b-a) && b >= a -} diff --git a/go/internal/telemetry/home_energy_test.go b/go/internal/telemetry/home_energy_test.go deleted file mode 100644 index c30bf556c..000000000 --- a/go/internal/telemetry/home_energy_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "math" - "testing" - "time" -) - -func homeSessionSample(s *Store, wh float64, session string, measured, received time.Time) { - data, _ := json.Marshal(map[string]any{ - "session_wh": wh, "session_id": session, "energy_observed_at": measured.Format(time.RFC3339Nano), - // Simulate a change-only power report that has not moved for an hour. - "control_power_observed_at": received.Add(-time.Hour).Format(time.RFC3339Nano), - "control_power_confirmed": true, - }) - homeDataSample(s, "cloud", DerEV, 9714, data, received) -} - -func homeDataSample(s *Store, driver string, kind DerType, w float64, data json.RawMessage, at time.Time) { - s.mu.Lock() - defer s.mu.Unlock() - s.recordHomeObservation(driver, kind, w, data, at) - s.readings[key(driver, kind)] = &DerReading{Driver: driver, DerType: kind, RawW: w, Data: data, UpdatedAt: at} - s.health[driver] = &DriverHealth{Name: driver} -} - -func TestHomeEstimatePrefersSessionEnergyWithMinuteDeliveryDelay(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 180; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - // A triangular charge ramp: exactly 50 Wh in each full minute. - phase := sec % 60 - ev := float64(phase) * 200 - if phase > 30 { - ev = float64(60-phase) * 200 - } - homeReceiptSample(s, "site", DerMeter, 1000+ev, at) - if sec >= 60 && sec%60 == 0 { - homeSessionSample(s, float64(sec/60-1)*50, "session-a", at.Add(-time.Minute), at) - } - if sec < 120 { - continue - } - got := s.HomeUseEstimate(at, "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.EnergyWh == nil || math.Abs(*got.EnergyWh-1000.0/60) > 1e-8 || got.EnergyBasis != "mixed" { - t.Fatalf("second %d: energy balance failed: %+v", sec, got) - } - if got.ToMS-got.FromMS != 60000 || got.EndAgeMS < 60000 || got.MinW != nil || got.MaxW != nil { - t.Fatalf("counter energy presented as instantaneous power/range: %+v", got) - } - } - // Display energy must not repair the bad raw power or change control evidence. - raw := s.ForecastMeasurement(base.Add(180*time.Second), "site", ForecastOptions{}) - if raw.Valid || raw.Reason != "invalid_balance" || raw.HouseholdW != -8714 { - t.Fatal(raw) - } -} - -func TestHomeEstimateAdaptsToCounterReportsAndDoesNotRefreshOnPoll(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 250; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 7000, at) - stamp := 0 - switch { - case sec >= 180: - stamp = 180 - case sec >= 60: - stamp = 60 - } - homeSessionSample(s, float64(stamp)*6000/3600, "session-a", base.Add(time.Duration(stamp)*time.Second), at) - if sec != 60 && sec != 180 && sec != 250 { - continue - } - got := s.HomeUseEstimate(at, "site", ForecastOptions{}) - period := int64(60000) - if sec >= 180 { - period = 120000 - } - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.ToMS-got.FromMS != period || got.EndAgeMS != int64(sec-stamp)*1000 { - t.Fatal(sec, got) - } - } -} - -func TestHomeSessionCounterBreaksAtInvalidEvidence(t *testing.T) { - base := time.Unix(100000, 0) - for _, scenario := range []string{"session", "reset", "missing_session", "missing_time", "missing_counter", "backward", "conflict", "future", "long_gap", "offline"} { - t.Run(scenario, func(t *testing.T) { - s := NewStore() - for sec := 0; sec <= 60; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 7000, at) - if sec%60 == 0 { - homeSessionSample(s, float64(sec)*6000/3600, "session-a", at, at) - } - } - now := base.Add(61 * time.Second) - d := map[string]any{"session_wh": 101, "session_id": "session-a", "energy_observed_at": now.Format(time.RFC3339Nano)} - switch scenario { - case "session": - d["session_id"] = "session-b" - case "reset": - d["session_wh"] = 0 - case "missing_session": - delete(d, "session_id") - case "missing_time": - delete(d, "energy_observed_at") - case "missing_counter": - delete(d, "session_wh") - case "backward": - d["energy_observed_at"] = base.Add(59 * time.Second).Format(time.RFC3339Nano) - case "conflict": - d["energy_observed_at"] = base.Add(60 * time.Second).Format(time.RFC3339Nano) - case "future": - d["energy_observed_at"] = now.Add(time.Second).Format(time.RFC3339Nano) - case "long_gap": - now = base.Add(361 * time.Second) - d["energy_observed_at"] = now.Format(time.RFC3339Nano) - case "offline": - s.health["cloud"].SetOffline() - } - data, _ := json.Marshal(d) - homeDataSample(s, "cloud", DerEV, 6000, data, now) - homeReceiptSample(s, "site", DerMeter, 7000, now) - if got := s.HomeUseEstimate(now, "site", ForecastOptions{}); got.Watts != nil { - t.Fatalf("crossed %s: %+v", scenario, got) - } - }) - } -} - -func TestHomeEnergyCannotFillLocalPowerGap(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 120; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - if sec < 30 || sec > 90 { - homeReceiptSample(s, "site", DerMeter, 7000, at) - } - if sec%60 == 0 { - homeSessionSample(s, float64(sec)*6000/3600, "session-a", at, at) - } - } - if got := s.HomeUseEstimate(base.Add(120*time.Second), "site", ForecastOptions{}); got.Watts != nil { - t.Fatal("counter filled missing meter energy", got) - } -} - -func TestHomePowerPeriodFollowsDistinctSourceReports(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 60; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 7000, at) - if sec%20 == 0 { - homeSample(s, "cloud", DerEV, 6000, at, at, false) - } - } - got := s.HomeUseEstimate(base.Add(time.Minute), "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.ToMS-got.FromMS != 20000 || got.EnergyBasis != "power_integral" { - t.Fatal(got) - } -} - -func TestHomeEnergyKeepsWholeReferenceCounterInterval(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 90; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 7000, at) - if sec == 0 || sec == 60 || sec == 90 { - homeSessionSample(s, float64(sec)*6000/3600, "session-a", at, at) - } - } - got := s.HomeUseEstimate(base.Add(90*time.Second), "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.FromMS != base.UnixMilli() || got.ToMS-got.FromMS != 90000 { - t.Fatal("split the reference counter interval after a cadence change", got) - } -} - -func TestHomeEnergyUsesCountersWithoutPowerTimeAndDoesNotMixDCEnergy(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 60; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 5000, at) - // Pixii: raw DC and cabinet counters do not describe the AC site flow. - homeDataSample(s, "battery", DerBattery, -2500, json.RawMessage(`{"control_power_w":-2000,"charge_wh":0,"discharge_wh":9000}`), at) - if sec%60 == 0 { - data, _ := json.Marshal(map[string]any{"session_wh": float64(sec) * 6000 / 3600, "session_id": "session-a", "energy_observed_at": at.Format(time.RFC3339Nano)}) - homeDataSample(s, "cloud", DerEV, 12345, data, at) - } - } - got := s.HomeUseEstimate(base.Add(time.Minute), "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 { - t.Fatal("counter path required power time or used DC energy", got) - } -} - -func TestHomeEnergyBalancesTwoCounterSchedules(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 135; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 11000, at) - for name, interval := range map[string]int{"charger-a": 60, "charger-b": 45} { - if sec%interval != 0 { - continue - } - w := 6000.0 - if name == "charger-b" { - w = 4000 - } - data, _ := json.Marshal(map[string]any{"session_wh": float64(sec) * w / 3600, "session_id": name, "energy_observed_at": at.Format(time.RFC3339Nano)}) - homeDataSample(s, name, DerEV, w, data, at) - } - } - for i := 0; i < 20; i++ { - got := s.HomeUseEstimate(base.Add(135*time.Second), "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.ToMS != base.Add(120*time.Second).UnixMilli() || got.FromMS != base.Add(time.Minute).UnixMilli() { - t.Fatal("counter schedules did not share a stable period", got) - } - } -} - -func TestHomeEstimateDropsSessionEnergyWhenDriverIsReplaced(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 60; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 7000, at) - if sec%60 == 0 { - homeSessionSample(s, float64(sec)*6000/3600, "session-a", at, at) - } - } - s.Remove("cloud") - // The replacement has source power but does not expose session energy. - for sec := 61; sec <= 81; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 7000, at) - homeSample(s, "cloud", DerEV, 6000, at, at, false) - } - got := s.HomeUseEstimate(base.Add(81*time.Second), "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.EnergyBasis != "power_integral" { - t.Fatal("replacement inherited the old counter requirement", got) - } -} - -func TestHomeEnergyRepeatedPollsCannotKeepAnOldIntervalFresh(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 241; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 7000, at) - stamp := 0 - if sec >= 60 { - stamp = 60 - } - homeSessionSample(s, float64(stamp)*6000/3600, "session-a", base.Add(time.Duration(stamp)*time.Second), at) - if sec < 240 { - continue - } - got := s.HomeUseEstimate(at, "site", ForecastOptions{}) - if sec == 240 && (got.Watts == nil || got.EndAgeMS != 180000) { - t.Fatal("lost supported past interval", got) - } - if sec == 241 && got.Watts != nil { - t.Fatal("fresh HTTP reply extended energy age", got) - } - } -} diff --git a/go/internal/telemetry/home_estimate.go b/go/internal/telemetry/home_estimate.go deleted file mode 100644 index 34eeb3315..000000000 --- a/go/internal/telemetry/home_estimate.go +++ /dev/null @@ -1,312 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "math" - "strings" - "time" -) - -const homeWindow = 10 * time.Second -const homeMaxGap = 30 * time.Second -const homeLookback = 3 * time.Minute -const homeMaxWindow = 5 * time.Minute - -// HomeEstimate describes a past interval, never a new instantaneous measurement. -// MinW/MaxW describe variation in the reported window, not error bounds. -// Reported source clocks and local receipt clocks are not verified as aligned. -type HomeEstimate struct { - Watts *float64 `json:"watts"` - EnergyWh *float64 `json:"energy_wh"` - EnergyBasis string `json:"energy_basis"` - MinW *float64 `json:"window_min_w"` - MaxW *float64 `json:"window_max_w"` - FromMS int64 `json:"from_ms"` - ToMS int64 `json:"to_ms"` - AgeMS int64 `json:"age_ms"` - EndAgeMS int64 `json:"end_age_ms"` - Method string `json:"method"` - TimingBasis string `json:"timing_basis"` - SourceAgeMS int64 `json:"source_age_ms"` - Reason string `json:"reason,omitempty"` -} - -// Keep a separate bounded buffer: neither control evidence nor raw readings may -// depend on how often a client asks for an estimate. Caller holds s.mu. -func (s *Store) recordHomeObservation(driver string, kind DerType, w float64, data json.RawMessage, now time.Time) { - if kind == DerVehicle { - return - } - s.recordHomeEnergy(driver, kind, data, now) - k := key(driver, kind) - if s.homeObservations == nil { - s.homeObservations = map[string][]ControlObservation{} - s.homeSourceTime = map[string]bool{} - s.homeIdleState = map[string]time.Time{} - } - p, ok := ControlPowerObservation(w, data, now) - var timing struct { - ControlPowerObservedAt string `json:"control_power_observed_at"` - PowerObservedAt string `json:"power_observed_at"` - ForecastPower *ForecastPowerSample `json:"forecast_power"` - } - _ = json.Unmarshal(data, &timing) - sourceTime := timing.ControlPowerObservedAt != "" || timing.PowerObservedAt != "" || timing.ForecastPower != nil - idleState := time.Time{} - if ok && kind == DerEV && w == 0 && p.PowerW == 0 { - idleState = homeDisconnectedState(data, now) - } - if !ok || ((kind == DerEV || kind == DerV2X) && !sourceTime && idleState.IsZero()) { - delete(s.homeObservations, k) - delete(s.homeSourceTime, k) - delete(s.homeIdleState, k) - return - } - if !idleState.IsZero() { - // A confirmed disconnected poll supports a zero-EV-load estimate at - // receipt time. It does not refresh the old power/session observation. - // Bracket only these polls, and start over after any state transition. - p.At, sourceTime = now, false - delete(s.homeEnergy, k) - } - // A successful cloud request can return an old observation. The driver's - // control confirmation is not a new power timestamp and cannot extend it. - p.ConfirmedAt = time.Time{} - points := s.homeObservations[k] - if !s.health[driver].TelemetryLive() || s.homeSourceTime[k] != sourceTime || !s.homeIdleState[k].Equal(idleState) { - points = nil - } - s.homeSourceTime[k] = sourceTime - s.homeIdleState[k] = idleState - if len(points) > 0 { - last := &points[len(points)-1] - if p.At.Before(last.At) || p.At.Sub(last.At) > homeMaxGap { - points = nil - } else if p.At.Equal(last.At) { - if p.PowerW != last.PowerW { - delete(s.homeObservations, k) - return - } - // A repeated change-only timestamp adds no time, even when a - // fresh HTTP reply carries a control confirmation. - s.homeObservations[k] = points - return - } - } - points = append(points, p) - cutoff := now.Add(-homeLookback - homeMaxWindow) - for len(points) > 1 && points[1].At.Before(cutoff) { - points = points[1:] - } - if len(points) > 1024 { - points = points[len(points)-1024:] - } - s.homeObservations[k] = points -} - -func homeDisconnectedState(data json.RawMessage, now time.Time) time.Time { - var d struct { - Connected *bool `json:"connected"` - Charging *bool `json:"charging"` - ControlPowerAvailable bool `json:"control_power_available"` - ControlPowerConfirmed bool `json:"control_power_confirmed"` - StateObservedAt string `json:"state_observed_at"` - SessionID string `json:"session_id"` - } - if json.Unmarshal(data, &d) != nil || d.Connected == nil || *d.Connected || d.Charging == nil || *d.Charging || !d.ControlPowerAvailable || !d.ControlPowerConfirmed || d.SessionID != "" { - return time.Time{} - } - at, err := time.Parse(time.RFC3339Nano, d.StateObservedAt) - if err != nil || at.After(now) { - return time.Time{} - } - return at -} - -// HomeUseEstimate divides signed energy by a common reported interval. Prefer -// timestamped session counters; otherwise integrate bracketed power. The period -// is at least ten seconds and at least the largest of each source's last three -// distinct reporting intervals. This is observed coverage, not a cadence promise. -// Clocks and sampling delay remain unverified. This path serves display only. -func (s *Store) HomeUseEstimate(now time.Time, siteMeter string, opts ForecastOptions) HomeEstimate { - out := HomeEstimate{Method: "reported_energy_balance_v3"} - if s == nil { - out.Reason = "no_telemetry" - return out - } - s.mu.RLock() - defer s.mu.RUnlock() - r := s.forecastMeasurementLocked(now, siteMeter, opts) - if !r.Valid && r.Reason != "invalid_balance" && r.Reason != "time_skew" && !strings.HasPrefix(r.Reason, "stale:") && !strings.HasPrefix(r.Reason, "unknown_power:") { - out.Reason = r.Reason - return out - } - to := now - window := homeWindow - counterCount := 0 - var reference *homeEnergySeries - var referenceKey string - var referenceGap time.Duration - var sourceClocks, receiptClocks bool - for k, source := range r.Sources { - points := s.homeObservations[k] - counter := s.homeEnergy[k] - if counter == nil && (source.Flow.DerType == DerEV || source.Flow.DerType == DerV2X) && !s.homeSourceTime[k] && s.homeIdleState[k].IsZero() { - out.Reason = "charger_source_time_unavailable" - return out - } - if !s.health[source.Flow.Driver].TelemetryLive() { - out.Reason = "missing_source_window" - return out - } - var times []time.Time - usesSourceTime := s.homeSourceTime[k] - if counter != nil { - for _, p := range counter.Points { - times = append(times, p.At) - } - usesSourceTime = true - counterCount++ - } else { - for _, p := range points { - times = append(times, p.At) - } - } - if len(times) < 2 { - out.Reason = "incomplete_source_window" - return out - } - last := times[len(times)-1] - if usesSourceTime && now.Sub(last).Milliseconds() > out.SourceAgeMS { - out.SourceAgeMS = now.Sub(last).Milliseconds() - } - if last.After(now) || now.Sub(last) > homeLookback { - out.Reason = "stale_source_window" - return out - } - if last.Before(to) { - to = last - } - var reportGap time.Duration - for i := len(times) - 1; i > 0 && i >= len(times)-3; i-- { - if gap := times[i].Sub(times[i-1]); gap > reportGap { - reportGap = gap - } - } - if reportGap > window { - window = reportGap - } - if counter != nil && (reference == nil || reportGap > referenceGap || (reportGap == referenceGap && k < referenceKey)) { - reference, referenceKey, referenceGap = counter, k, reportGap - } - sourceClocks = sourceClocks || usesSourceTime - receiptClocks = receiptClocks || !usesSourceTime - } - out.TimingBasis = "source" - if receiptClocks { - out.TimingBasis = "receipt" - if sourceClocks { - out.TimingBasis = "mixed" - } - } - from := to.Add(-window) - if reference != nil { - // Keep the slowest counter's complete interval wherever possible. - // Other counter clocks may require cumulative-energy interpolation. - endFound, startFound := false, false - for i := len(reference.Points) - 1; i >= 0; i-- { - at := reference.Points[i].At - if !endFound && !at.After(to) { - to, endFound = at, true - from = to.Add(-window) - } - if endFound && !at.After(from) { - from, startFound = at, true - break - } - } - if !startFound || to.Sub(from) > homeMaxWindow || now.Sub(to) > homeLookback { - out.Reason = "incomplete_source_window" - return out - } - window = to.Sub(from) - } - out.FromMS, out.ToMS = from.UnixMilli(), to.UnixMilli() - out.AgeMS = now.Sub(from.Add(window / 2)).Milliseconds() - out.EndAgeMS = now.Sub(to).Milliseconds() - out.EnergyBasis = "power_integral" - if counterCount > 0 { - out.EnergyBasis = "counters" - if counterCount < len(r.Sources) { - out.EnergyBasis = "mixed" - } - } - var energy, low, high float64 - for k, source := range r.Sources { - var wh, minW, maxW float64 - var ok bool - if counter := s.homeEnergy[k]; counter != nil { - wh, ok = homeCounterEnergy(counter.Points, from, to) - } else { - var avg float64 - avg, minW, maxW, ok = homeWindowPower(s.homeObservations[k], from, to) - wh = avg * window.Hours() - } - if !ok { - out.Reason = "incomplete_source_window" - return out - } - if source.Flow.DerType == DerMeter { - energy += wh - low += minW - high += maxW - } else { - energy -= wh - low -= maxW - high -= minW - } - } - mean := energy / window.Hours() - if !finite(mean) || !finite(low) || !finite(high) || mean < 0 { - out.Reason = "invalid_balance" - return out - } - out.Watts, out.EnergyWh = &mean, &energy - if counterCount == 0 { - out.MinW, out.MaxW = &low, &high - } - return out -} - -func homeWindowPower(points []ControlObservation, from, to time.Time) (mean, low, high float64, ok bool) { - covered := time.Duration(0) - low, high = math.Inf(1), math.Inf(-1) - add := func(a, b time.Time, wa, wb float64) { - left, right := a, b - if left.Before(from) { - left = from - } - if right.After(to) { - right = to - } - if !right.After(left) { - return - } - span := b.Sub(a).Seconds() - v0 := wa + (wb-wa)*left.Sub(a).Seconds()/span - v1 := wa + (wb-wa)*right.Sub(a).Seconds()/span - mean += (v0 + v1) / 2 * right.Sub(left).Seconds() - low = math.Min(low, math.Min(v0, v1)) - high = math.Max(high, math.Max(v0, v1)) - covered += right.Sub(left) - } - for i, p := range points { - if i+1 < len(points) { - next := points[i+1] - if next.At.After(p.At) && next.At.Sub(p.At) <= homeMaxGap { - add(p.At, next.At, p.PowerW, next.PowerW) - } - } - } - return mean / to.Sub(from).Seconds(), low, high, covered == to.Sub(from) -} diff --git a/go/internal/telemetry/home_estimate_test.go b/go/internal/telemetry/home_estimate_test.go deleted file mode 100644 index 423713e02..000000000 --- a/go/internal/telemetry/home_estimate_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "fmt" - "math" - "testing" - "time" -) - -func homeSample(s *Store, name string, kind DerType, w float64, measured, received time.Time, confirmed bool) { - data, _ := json.Marshal(map[string]any{"power_observed_at": measured.Format(time.RFC3339Nano), "control_power_confirmed": confirmed}) - s.mu.Lock() - defer s.mu.Unlock() - s.recordHomeObservation(name, kind, w, data, received) - s.readings[key(name, kind)] = &DerReading{Driver: name, DerType: kind, RawW: w, UpdatedAt: received, Data: data} - s.health[name] = &DriverHealth{Name: name} -} - -// This is a synthetic, timestamped replay of the reported failure class, not -// Göran's missing raw 19:32 trace. A four-second delivery delay is a test input. -func TestHomeEstimateAlignedReplayVersusIndependentKalman(t *testing.T) { - s := NewStore() - base := time.Date(2026, 10, 17, 17, 32, 0, 0, time.UTC) - gridFilter, evFilter := NewKalman(100, 50), NewKalman(100, 50) - var alignedError, filteredError float64 - var count int - for second := 0; second <= 100; second++ { - at := base.Add(time.Duration(second) * time.Second) - ev := func(sec int) float64 { - if sec >= 10 && sec < 40 || sec >= 60 && sec < 75 { - return 9714 - } - return 0 - } - homeSample(s, "site", DerMeter, 1000+ev(second), at, at, false) - grid := gridFilter.Update(1000 + ev(second)) - if second >= 4 { - measured := at.Add(-4 * time.Second) - homeSample(s, "ev", DerEV, ev(second-4), measured, at, false) - evFilter.Update(ev(second - 4)) - } - if second < 14 { - continue - } - out := s.HomeUseEstimate(at, "site", ForecastOptions{}) - if out.Watts == nil { - t.Fatalf("second %d: %+v", second, out) - } - alignedError += math.Abs(*out.Watts - 1000) - filteredError += math.Abs(grid - evFilter.Estimate - 1000) - if out.ToMS != at.Add(-4*time.Second).UnixMilli() || out.AgeMS != 9000 { - t.Fatal(out) - } - count++ - } - if alignedError > 1e-8 || filteredError/float64(count) < 500 { - t.Fatal(alignedError, filteredError, count) - } - t.Logf("synthetic replay MAE: aligned %.3f W, independent scalar Kalman %.1f W", alignedError/float64(count), filteredError/float64(count)) -} - -func TestHomeWindowTimeWeightingAndSignedEnergy(t *testing.T) { - base := time.Unix(1000, 0) - for _, offsets := range [][]int{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, {0, 2, 9, 10}} { - var p []ControlObservation - for _, sec := range offsets { - p = append(p, ControlObservation{PowerW: float64(sec) * 100, At: base.Add(time.Duration(sec) * time.Second)}) - } - mean, low, high, ok := homeWindowPower(p, base, base.Add(10*time.Second)) - if !ok || math.Abs(mean-500) > 1e-9 || low != 0 || high != 1000 { - t.Fatal(mean, low, high, ok) - } - } - // Integrating then subtracting must equal subtracting the signed energies. - p := []ControlObservation{{PowerW: -1000, At: base}, {PowerW: 3000, At: base.Add(10 * time.Second)}} - mean, _, _, ok := homeWindowPower(p, base, base.Add(10*time.Second)) - if !ok || mean != 1000 { - t.Fatal(mean, ok) - } -} - -func TestHomeEstimateChangeOnlyConfirmationAndCachedRepeats(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - original := base.Add(-time.Hour) - for sec := 0; sec <= 20; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeSample(s, "site", DerMeter, 1500, at, at, false) - homeSample(s, "ev", DerEV, 500, original, at, true) - } - got := s.HomeUseEstimate(base.Add(20*time.Second), "site", ForecastOptions{}) - if got.Watts != nil || len(s.homeObservations["ev:ev"]) != 1 { - t.Fatal(got) - } - // Asking repeatedly must not advance the interval or modify its source. - for i := 0; i < 100; i++ { - again := s.HomeUseEstimate(base.Add(25*time.Second), "site", ForecastOptions{}) - if again.Watts != nil || again.ToMS != got.ToMS { - t.Fatal(again) - } - } - if got := s.HomeUseEstimate(base.Add(51*time.Second), "site", ForecastOptions{}); got.Watts != nil { - t.Fatal(got) - } - // An unconfirmed duplicate cannot extend a plateau. - u := NewStore() - for sec := 0; sec <= 20; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeSample(u, "site", DerMeter, 1000, base, at, false) - } - if got := u.HomeUseEstimate(base.Add(20*time.Second), "site", ForecastOptions{}); got.Watts != nil { - t.Fatal(got) - } -} - -func TestHomeEstimateRejectsGapsAndConflicts(t *testing.T) { - base := time.Unix(100000, 0) - for _, scenario := range []string{"offline", "long_gap", "backward", "conflict", "future", "unknown", "restart", "duplicate_topology"} { - t.Run(scenario, func(t *testing.T) { - s := NewStore() - for sec := 0; sec <= 10; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeSample(s, "site", DerMeter, 1000, at, at, false) - } - now := base.Add(10 * time.Second) - opts := ForecastOptions{} - switch scenario { - case "offline": - s.health["site"].SetOffline() - case "long_gap": - now = base.Add(50 * time.Second) - homeSample(s, "site", DerMeter, 1000, now, now, false) - case "backward": - homeSample(s, "site", DerMeter, 1000, base.Add(5*time.Second), now, false) - case "conflict": - homeSample(s, "site", DerMeter, 2000, now, now, false) - case "future": - homeSample(s, "site", DerMeter, 1000, now.Add(time.Second), now, false) - case "unknown": - s.recordHomeObservation("site", DerMeter, 1000, json.RawMessage(`{"control_power_available":false}`), now) - case "restart": - s = NewStore() - homeSample(s, "site", DerMeter, 1000, now, now, false) - case "duplicate_topology": - opts.ExpectedFlows = []ForecastFlow{{Driver: "site", DerType: DerMeter, FlowID: "x"}, {Driver: "site", DerType: DerMeter, FlowID: "x"}} - } - if got := s.HomeUseEstimate(now, "site", opts); got.Watts != nil { - t.Fatal(got) - } - }) - } -} - -func TestHomeEstimateDoesNotRepairRawNegativeBalance(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 20; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - ev := 9714.0 - grid := 10600.0 - bat := 0.0 - if sec >= 20 { - grid = -267 - bat = -6035 - } - homeSample(s, "site", DerMeter, grid, at, at, false) - homeSample(s, "bat", DerBattery, bat, at, at, false) - // The slow source has not reported the stop yet. - if sec <= 16 { - homeSample(s, "ev", DerEV, ev, at, at, false) - } - } - now := base.Add(20 * time.Second) - for i := 0; i < 5; i++ { - raw := s.ForecastMeasurement(now, "site", ForecastOptions{}) - if raw.Valid || raw.Reason != "invalid_balance" || raw.HouseholdW != -3946 { - t.Fatal(raw) - } - got := s.HomeUseEstimate(now, "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-886) > 1e-8 || got.AgeMS != 9000 { - t.Fatal(fmt.Sprintf("%+v", got)) - } - } -} - -func TestHomeWindowCannotExtendThroughControlConfirmations(t *testing.T) { - base := time.Unix(100000, 0) - points := []ControlObservation{ - {At: base, ConfirmedAt: base.Add(20 * time.Second), PowerW: 1000}, - {At: base.Add(19 * time.Second), ConfirmedAt: base.Add(40 * time.Second), PowerW: 2000}, - } - // Control confirmations are HTTP/status evidence, not source brackets. - if _, _, _, ok := homeWindowPower(points, base.Add(15*time.Second), base.Add(25*time.Second)); ok { - t.Fatal("overlap treated as certain") - } - mean, _, _, ok := homeWindowPower(points, base.Add(30*time.Second), base.Add(40*time.Second)) - if ok { - t.Fatal("confirmation extended source evidence", mean, ok) - } -} diff --git a/go/internal/telemetry/home_idle_test.go b/go/internal/telemetry/home_idle_test.go deleted file mode 100644 index 2cf6128dc..000000000 --- a/go/internal/telemetry/home_idle_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "math" - "testing" - "time" -) - -func homeDisconnectedData(base time.Time) map[string]any { - return map[string]any{ - "connected": false, "charging": false, - "control_power_observed_at": base.Add(-time.Hour).Format(time.RFC3339Nano), - "control_power_available": true, "control_power_confirmed": true, - "state_observed_at": base.Add(-time.Hour).Format(time.RFC3339Nano), - } -} - -func homeIdleSample(t *testing.T, s *Store, w float64, fields map[string]any, at time.Time) { - t.Helper() - data, err := json.Marshal(fields) - if err != nil { - t.Fatal("cannot encode charger evidence") - } - homeDataSample(s, "charger", DerEV, w, data, at) -} - -// Easee can retain the last session's energy and change-only power timestamps -// for hours after unplugging. Confirmed disconnected polls describe zero EV -// load, not fresh measurements of that old session. -func TestHomeEstimateDisconnectedChargerWithOldObservations(t *testing.T) { - for _, oldEnergy := range []bool{false, true} { - t.Run(map[bool]string{false: "power_only", true: "previous_session_energy"}[oldEnergy], func(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - fields := homeDisconnectedData(base) - if oldEnergy { - fields["session_wh"] = 5000 - fields["energy_observed_at"] = base.Add(-time.Hour).Format(time.RFC3339Nano) - } - for sec := 0; sec <= 600; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, -1500, at) - homeReceiptSample(s, "pv", DerPV, -3000, at) - homeReceiptSample(s, "battery", DerBattery, 500, at) - if sec%15 == 0 { - homeIdleSample(t, s, 0, fields, at) - } - } - now := base.Add(600 * time.Second) - got := s.HomeUseEstimate(now, "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.Reason != "" { - t.Fatalf("disconnected charger hid household load: %+v", got) - } - if got.TimingBasis != "receipt" || got.ToMS != now.UnixMilli() || got.ToMS-got.FromMS != 15000 || got.EnergyBasis != "power_integral" { - t.Fatalf("wrong interval or clock basis: %+v", got) - } - // Display estimates must leave raw power and control evidence alone. - reading := s.readings[key("charger", DerEV)] - p, ok := ControlPowerObservation(reading.RawW, reading.Data, now) - if !ok || !p.At.Equal(base.Add(-time.Hour)) || !p.ConfirmedAt.Equal(now) || reading.RawW != 0 { - t.Fatal("display estimate changed source/control evidence") - } - // Asking again cannot extend the interval without another poll. - again := s.HomeUseEstimate(now.Add(time.Second), "site", ForecastOptions{}) - if again.ToMS != got.ToMS || again.EndAgeMS != 1000 { - t.Fatal("client reads extended disconnected coverage") - } - if stale := s.HomeUseEstimate(now.Add(homeLookback+time.Second), "site", ForecastOptions{}); stale.Watts != nil { - t.Fatal("old disconnected polls remained usable") - } - }) - } -} - -func TestHomeEstimateDoesNotInferDisconnectedZeroFromOtherEvidence(t *testing.T) { - for _, scenario := range []string{"connected", "charging", "missing_connected", "missing_charging", "unconfirmed", "unavailable", "missing_availability", "nonzero", "control_nonzero", "session", "missing_state_time", "bad_state_time", "future_state_time", "future_power_time", "offline"} { - t.Run(scenario, func(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - fields := homeDisconnectedData(base) - w := 0.0 - switch scenario { - case "connected": - fields["connected"] = true - case "charging": - fields["charging"] = true - case "missing_connected": - delete(fields, "connected") - case "missing_charging": - delete(fields, "charging") - case "unconfirmed": - fields["control_power_confirmed"] = false - case "unavailable": - fields["control_power_available"] = false - case "missing_availability": - delete(fields, "control_power_available") - case "nonzero": - w = 500 - case "control_nonzero": - fields["control_power_w"] = 500 - case "session": - fields["session_id"] = "active-session" - case "missing_state_time": - delete(fields, "state_observed_at") - case "bad_state_time": - fields["state_observed_at"] = "invalid" - case "future_state_time": - fields["state_observed_at"] = base.Add(time.Hour).Format(time.RFC3339Nano) - case "future_power_time": - fields["control_power_observed_at"] = base.Add(time.Hour).Format(time.RFC3339Nano) - } - for sec := 0; sec <= 60; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 1000+w, at) - if sec%15 == 0 { - homeIdleSample(t, s, w, fields, at) - } - } - if scenario == "offline" { - s.health["charger"].SetOffline() - } - if got := s.HomeUseEstimate(base.Add(time.Minute), "site", ForecastOptions{}); got.Watts != nil { - t.Fatalf("%s became a zero-load interval: %+v", scenario, got) - } - }) - } -} - -func TestHomeDisconnectedCoverageBreaksAtTransitions(t *testing.T) { - for _, scenario := range []string{"reconnect", "unknown", "state_change", "offline", "gap", "remove"} { - t.Run(scenario, func(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - fields := homeDisconnectedData(base) - for sec := 0; sec <= 60; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 1000, at) - if sec%15 == 0 { - homeIdleSample(t, s, 0, fields, at) - } - } - if got := s.HomeUseEstimate(base.Add(time.Minute), "site", ForecastOptions{}); got.Watts == nil { - t.Fatal("missing initial disconnected interval") - } - now := base.Add(61 * time.Second) - switch scenario { - case "reconnect": - fields["connected"] = true - case "unknown": - fields["control_power_available"] = false - case "state_change": - fields["state_observed_at"] = now.Format(time.RFC3339Nano) - case "offline": - s.health["charger"].SetOffline() - case "gap": - now = base.Add(91 * time.Second) - case "remove": - s.Remove("charger") - } - homeReceiptSample(s, "site", DerMeter, 1000, now) - homeIdleSample(t, s, 0, fields, now) - if got := s.HomeUseEstimate(now, "site", ForecastOptions{}); got.Watts != nil { - t.Fatalf("crossed %s: %+v", scenario, got) - } - // A newly bracketed disconnected interval may become usable again. - for sec := 1; sec <= 30; sec++ { - at := now.Add(time.Duration(sec) * time.Second) - homeReceiptSample(s, "site", DerMeter, 1000, at) - if sec%15 == 0 { - homeIdleSample(t, s, 0, homeDisconnectedData(base), at) - } - } - got := s.HomeUseEstimate(now.Add(30*time.Second), "site", ForecastOptions{}) - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 || got.FromMS < now.UnixMilli() { - t.Fatalf("new interval did not recover after %s: %+v", scenario, got) - } - }) - } -} - -func TestHomeEstimateKeepsChargingCountersSeparateFromDisconnectedCoverage(t *testing.T) { - s := NewStore() - base := time.Unix(100000, 0) - for sec := 0; sec <= 180; sec++ { - at := base.Add(time.Duration(sec) * time.Second) - grid := 7000.0 - if sec > 60 && sec <= 90 { - grid = 1000 - } - homeReceiptSample(s, "site", DerMeter, grid, at) - if sec%15 != 0 { - continue - } - fields := homeDisconnectedData(base) - w := 6000.0 - fields["session_wh"] = float64(sec) * w / 3600 - fields["energy_observed_at"] = at.Format(time.RFC3339Nano) - switch { - case sec <= 60: - fields["connected"], fields["charging"], fields["session_id"] = true, true, "first-session" - case sec <= 90: - w = 0 - fields["state_observed_at"] = base.Add(61 * time.Second).Format(time.RFC3339Nano) - fields["energy_observed_at"] = base.Add(time.Minute).Format(time.RFC3339Nano) - fields["session_wh"] = 100 - default: - fields["connected"], fields["charging"], fields["session_id"] = true, true, "second-session" - fields["session_wh"] = float64(sec-90) * w / 3600 - } - homeIdleSample(t, s, w, fields, at) - got := s.HomeUseEstimate(at, "site", ForecastOptions{}) - switch sec { - case 60, 90, 180: - if got.Watts == nil || math.Abs(*got.Watts-1000) > 1e-8 { - t.Fatalf("lost a supported interval at %d: %+v", sec, got) - } - if sec != 90 && got.EnergyBasis != "mixed" { - t.Fatal("charging no longer uses session energy") - } - case 75, 105: - if got.Watts != nil { - t.Fatal("estimate crossed a plug/session transition") - } - } - } -} diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index d25cebcbf..23ed376fa 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -296,10 +296,6 @@ type Store struct { health map[string]*DriverHealth commands map[string]CommandEvidence controlObservations map[string][]ControlObservation - homeObservations map[string][]ControlObservation // display estimates only - homeSourceTime map[string]bool // reported source time, otherwise receipt time - homeIdleState map[string]time.Time // disconnected state time; zero means unqualified - homeEnergy map[string]*homeEnergySeries // timestamped session counters, display only processNoise float64 measurementNoise float64 @@ -478,7 +474,6 @@ func (s *Store) Update(driver string, t DerType, rawW float64, soc *float64, dat s.observeCommand(driver, t, rawW, data, now) s.recordControlObservation(driver, t, rawW, data, now) - s.recordHomeObservation(driver, t, rawW, data, now) s.freezeStepWindows(now) // Auto-buffer the standard fields (raw, not smoothed — we store ground @@ -785,10 +780,6 @@ func (s *Store) Remove(driver string) { delete(s.health, driver) for _, kind := range allDerTypes { delete(s.controlObservations, key(driver, kind)) - delete(s.homeObservations, key(driver, kind)) - delete(s.homeSourceTime, key(driver, kind)) - delete(s.homeIdleState, key(driver, kind)) - delete(s.homeEnergy, key(driver, kind)) } for k, c := range s.commands { c.Baseline, c.StepAfter = nil, nil diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index ca942b21f..a4741baba 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -208,7 +208,7 @@ export function flowReadingsFromStatus(status, opts) { } // Core says "estimated" when it left an unread device out of Home use. - const hasEstimate = status?.load_estimate != null || status?.load_quality === "estimated"; + const hasEstimate = 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 027f1be65..2977a3f10 100644 --- a/web/energy-flow-readings.test.mjs +++ b/web/energy-flow-readings.test.mjs @@ -5,10 +5,11 @@ import { flowReadingsFromStatus, fmtKwhShort } from "./components/energy-flow-re it("shows current household power without a note about the past energy mean", () => { const estimate = {watts: 886, from_ms: Date.UTC(2026,9,18,10), to_ms: Date.UTC(2026,9,18,10,1), age_ms: 90000, end_age_ms:60000, energy_basis:'mixed', timing_basis: 'mixed'}; - const status = {load_w: 1800, load_estimate: estimate, energy:{today:{load_wh:2000,import_wh:500,history_quality:'estimated'}}}; + const status = {load_w: 1800, load_quality: "measured", load_estimate: estimate, energy:{today:{load_wh:2000,import_wh:500,history_quality:'estimated'}}}; const result = flowReadingsFromStatus(status); assert.equal(result.load, 1.8); - assert.equal(result.loadEstimated, true); + // An older Core also sent an energy mean; it never marks live Home use. + assert.equal(result.loadEstimated, false); assert.equal(status.load_w, 1800); assert.equal(result.selfPoweredPctToday, null); assert.equal("homeEstimateNote" in result, false); @@ -17,6 +18,14 @@ it("shows current household power without a note about the past energy mean", () assert.equal(flowReadingsFromStatus({...status,load_w:null}).load, null); }); +it("marks Home use estimated only when Core says so", () => { + for (const [quality, estimated] of [["measured", false], [undefined, false], ["estimated", true], ["unknown", false]]) { + const result = flowReadingsFromStatus({load_w: 640, load_quality: quality, history_quality: "complete"}); + assert.equal(result.load, 0.64); + assert.equal(result.loadEstimated, estimated, String(quality)); + } +}); + it("never lets a past zero mean overwrite a positive current Load", () => { const status={load_w:1234,history_quality:"complete",load_estimate:{watts:0,from_ms:1000,to_ms:11000,end_age_ms:60000}}; const result=flowReadingsFromStatus(status); @@ -27,7 +36,7 @@ it("never lets a past zero mean overwrite a positive current Load", () => { it("keeps a qualified power balance visible while the energy interval is unavailable", () => { for (const reason of ["incomplete_source_window", "charger_source_time_unavailable", "stale_source_window"]) { - const status = {load_w: 1234, history_quality: "complete", load_estimate:{watts:null, reason}}; + const status = {load_w: 1234, history_quality: "complete", load_quality: "estimated", load_estimate:{watts:null, reason}}; const result = flowReadingsFromStatus(status); assert.equal(result.load, 1.234); assert.equal(result.loadEstimated, true);