From 14e3e47c33d78b3c890156e65afaddfea1336e6b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:51:00 +0200 Subject: [PATCH 1/2] fix(loadpoint): order vehicle anchors by measurement time Signed-off-by: Fredrik Ahlgren --- .changeset/vehicle-soc-measurement-order.md | 5 ++ go/internal/loadpoint/loadpoint.go | 6 +- .../loadpoint/vehicle_anchor_order_test.go | 71 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 .changeset/vehicle-soc-measurement-order.md create mode 100644 go/internal/loadpoint/vehicle_anchor_order_test.go diff --git a/.changeset/vehicle-soc-measurement-order.md b/.changeset/vehicle-soc-measurement-order.md new file mode 100644 index 000000000..86741673f --- /dev/null +++ b/.changeset/vehicle-soc-measurement-order.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Compare delayed car charge readings by measurement time. A newer car reading can now replace an older one even when both arrive late. Keep newer manual corrections and count energy delivered since the measurement once. diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 530348366..00d162ba1 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -440,7 +440,7 @@ type loadpointRuntime struct { // socConfirmed is true only after a level from the user or a matched car. // A configured/default plug-in level remains a planning assumption. - // socConfirmedAt is when that confirmation happened. Zero means the + // socConfirmedAt is when the user set it or the car measured it. Zero means the // time was not kept (for example a level restored after restart); // a car reading may then replace it. A newer confirmation wins over // an older car measurement. @@ -1161,6 +1161,10 @@ func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time. } } reanchorSoCAtLocked(lp, socPct, deliveredAt, m.now()) + if !observedAt.IsZero() { + // Compare car measurements by source time, not cloud delivery time. + lp.socConfirmedAt = observedAt + } lp.vehicleAnchored = true if lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && !lp.completionNotified { lp.completionNotified = true diff --git a/go/internal/loadpoint/vehicle_anchor_order_test.go b/go/internal/loadpoint/vehicle_anchor_order_test.go new file mode 100644 index 000000000..e11d15114 --- /dev/null +++ b/go/internal/loadpoint/vehicle_anchor_order_test.go @@ -0,0 +1,71 @@ +package loadpoint + +import ( + "math" + "testing" + "time" +) + +// Cloud delivery order must not replace the car's measurement times. Both +// readings arrive late; the second was measured after the first. +func TestDelayedVehicleSoCUsesMeasurementOrder(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "car", VehicleCapacityWh: 86500, PluginSoC: .60}}) + start := time.Date(2026, 10, 15, 10, 0, 0, 0, time.UTC) + now := start + m.SetNowFn(func() time.Time { return now }) + m.Observe("car", true, 0, 0, true) + if !m.SetCurrentSoC("car", .60) { + t.Fatal("manual starting level refused") + } + for i := 1; i <= 8; i++ { + now = start.Add(time.Duration(i) * 5 * time.Minute) + m.Observe("car", true, 6000, float64(i)*500, true) + } + first := start.Add(10 * time.Minute) + if !m.VehicleSoCSupersedes("car", first) || !m.AnchorVehicleSoCAt("car", .62, first) { + t.Fatal("first delayed car reading refused") + } + now = start.Add(45 * time.Minute) + m.Observe("car", true, 6000, 4500, true) + second := start.Add(20 * time.Minute) + if !m.VehicleSoCSupersedes("car", second) { + t.Fatal("newer car measurement lost to the first reading's receipt time") + } + want := .68 + 2500*DefaultChargeEfficiency/86500 + if got, fromVehicle := m.PlanningStartSoC("car", .68, second, true); !fromVehicle || math.Abs(got-want) > 1e-9 { + t.Fatalf("planner start %.12f fromVehicle=%t, want %.12f", got, fromVehicle, want) + } + if !m.AnchorVehicleSoCAt("car", .68, second) { + t.Fatal("second delayed car reading refused") + } + st, _ := m.State("car") + if math.Abs(st.CurrentSoC-want) > 1e-9 || st.UpdatedAtMs != now.UnixMilli() { + t.Fatalf("wrong corrected level or receipt time: %+v", st) + } + for _, observed := range []time.Time{time.Time{}, first, second} { + if m.VehicleSoCSupersedes("car", observed) { + t.Fatalf("old or repeated measurement became newer: %v", observed) + } + if got, fromVehicle := m.PlanningStartSoC("car", .68, observed, true); fromVehicle || math.Abs(got-want) > 1e-9 { + t.Fatalf("old reading changed the estimate or counted energy twice: %.12f, %t", got, fromVehicle) + } + } + // A later explicit user correction still takes precedence over a car + // measurement taken before it, even when that reading arrives afterwards. + now = start.Add(46 * time.Minute) + if !m.SetCurrentSoC("car", .71) { + t.Fatal("manual correction refused") + } + olderThanManual := start.Add(44 * time.Minute) + if m.VehicleSoCSupersedes("car", olderThanManual) { + t.Fatal("a delayed car reading replaced a newer manual correction") + } + if got, fromVehicle := m.PlanningStartSoC("car", .68, olderThanManual, true); fromVehicle || math.Abs(got-.71) > 1e-9 { + t.Fatalf("manual correction lost: %.12f, %t", got, fromVehicle) + } + now = now.Add(2 * time.Minute) + if !m.VehicleSoCSupersedes("car", now.Add(-time.Minute)) { + t.Fatal("a car reading measured after the manual correction cannot replace it") + } +} From ab55b3fef8a90d3cdc4cc853cd920450e0fdf37b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:11:13 +0200 Subject: [PATCH 2/2] fix(loadpoint): retain source time without energy history Signed-off-by: Fredrik Ahlgren --- .changeset/vehicle-soc-measurement-order.md | 2 +- go/internal/loadpoint/loadpoint.go | 22 ++++++------ .../loadpoint_anchor_vehicle_test.go | 30 +++++++++++----- .../loadpoint/vehicle_anchor_order_test.go | 35 +++++++++++++++++++ 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/.changeset/vehicle-soc-measurement-order.md b/.changeset/vehicle-soc-measurement-order.md index 86741673f..255db32a3 100644 --- a/.changeset/vehicle-soc-measurement-order.md +++ b/.changeset/vehicle-soc-measurement-order.md @@ -2,4 +2,4 @@ "ftw": patch --- -Compare delayed car charge readings by measurement time. A newer car reading can now replace an older one even when both arrive late. Keep newer manual corrections and count energy delivered since the measurement once. +Compare delayed car charge readings by measurement time. A newer car reading can now replace an older one even when both arrive late. Keep newer manual corrections and count energy delivered since the measurement once. Keep the measurement time when restart or trimmed history requires using the raw reading. diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 00d162ba1..3d22a9716 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -1125,10 +1125,10 @@ func (m *Manager) AnchorVehicleSoC(id string, socPct float64) bool { return m.AnchorVehicleSoCAt(id, socPct, time.Time{}) } -// AnchorVehicleSoCAt anchors a car reading at the time the car measured -// it. Energy delivered since then moves the estimate past the reading, so -// a cloud SoC that is half an hour old stays useful. A zero observedAt -// means now. False when the session's energy at observedAt is unknown. +// AnchorVehicleSoCAt anchors a qualified car reading at its measurement +// time. Known energy delivered since then moves the estimate forward. When +// history does not cover that time, adopt the reading without adding unknown +// energy. A zero observedAt means now. func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time.Time) bool { m.sessionMu.Lock() m.mu.Lock() @@ -1150,16 +1150,14 @@ func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time. return false } deliveredAt := lp.deliveredWhSession - if !observedAt.IsZero() { - // A reading from before plug-in may predate a drive home. - if observedAt.Before(lp.connectionObservedAt.Add(-anchorBeforePlugSlack)) { - return false - } - var ok bool - if deliveredAt, ok = lp.deliveredAt(observedAt); !ok { - return false + if !observedAt.IsZero() && !observedAt.Before(lp.connectionObservedAt.Add(-anchorBeforePlugSlack)) { + if known, ok := lp.deliveredAt(observedAt); ok { + deliveredAt = known } } + // A reading before this connection or outside retained history carries + // no known energy delta. Keep its source time even in that raw fallback; + // returning false made the caller re-anchor it as a reading taken now. reanchorSoCAtLocked(lp, socPct, deliveredAt, m.now()) if !observedAt.IsZero() { // Compare car measurements by source time, not cloud delivery time. diff --git a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go index b7811297c..99dc96fe7 100644 --- a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go +++ b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go @@ -164,8 +164,11 @@ func TestAnchorVehicleSoCAtBeforePlugIn(t *testing.T) { now = t0.Add(10 * time.Minute) m.Observe("a", true, 11000, 1500, true) - if m.AnchorVehicleSoCAt("a", 0.60, t0.Add(-anchorBeforePlugSlack-time.Minute)) { - t.Fatal("a reading long before plug-in may predate a drive") + if !m.AnchorVehicleSoCAt("a", 0.60, t0.Add(-anchorBeforePlugSlack-time.Minute)) { + t.Fatal("qualified pre-connection reading refused") + } + if st, _ := m.State("a"); math.Abs(st.CurrentSoC-.60) > 1e-9 { + t.Fatalf("energy before the connection was invented: %.4f", st.CurrentSoC) } if !m.AnchorVehicleSoCAt("a", 0.60, t0.Add(-time.Minute)) { t.Fatal("a reading just before plug-in should anchor") @@ -175,17 +178,23 @@ func TestAnchorVehicleSoCAtBeforePlugIn(t *testing.T) { } } -// After a restart the history starts mid-session. Energy at an earlier -// reading is unknown, so the reading must not anchor. -func TestAnchorVehicleSoCAtUnknownEnergyRefuses(t *testing.T) { +// After a restart the history starts mid-session. Keep the qualified raw +// reading and its source time without adding unknown earlier energy. +func TestAnchorVehicleSoCAtUnknownEnergyKeepsRawReading(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.25}}) t0 := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) now := t0 m.SetNowFn(func() time.Time { return now }) m.Observe("a", true, 11000, 4000, true) - if m.AnchorVehicleSoCAt("a", 0.60, t0.Add(-2*time.Minute)) { - t.Fatal("energy before the first sample is unknown") + if !m.AnchorVehicleSoCAt("a", 0.60, t0.Add(-2*time.Minute)) { + t.Fatal("qualified reading with unknown earlier energy refused") + } + if st, _ := m.State("a"); math.Abs(st.CurrentSoC-.60) > 1e-9 { + t.Fatalf("unknown earlier energy was added: %.4f", st.CurrentSoC) + } + if !m.VehicleSoCSupersedes("a", t0.Add(-time.Minute)) { + t.Fatal("raw fallback used receipt time instead of measurement time") } if !m.AnchorVehicleSoCAt("a", 0.60, t0) { t.Fatal("a reading at the first sample should anchor") @@ -195,7 +204,10 @@ func TestAnchorVehicleSoCAtUnknownEnergyRefuses(t *testing.T) { now = t0.Add(time.Duration(i) * 5 * time.Minute) m.Observe("a", true, 11000, 4000+float64(i)*100, true) } - if m.AnchorVehicleSoCAt("a", 0.60, t0) { - t.Fatal("trimmed history must not anchor") + if !m.AnchorVehicleSoCAt("a", 0.60, t0) { + t.Fatal("qualified reading outside retained history refused") + } + if st, _ := m.State("a"); math.Abs(st.CurrentSoC-.60) > 1e-9 { + t.Fatalf("trimmed energy history was invented: %.4f", st.CurrentSoC) } } diff --git a/go/internal/loadpoint/vehicle_anchor_order_test.go b/go/internal/loadpoint/vehicle_anchor_order_test.go index e11d15114..72c401085 100644 --- a/go/internal/loadpoint/vehicle_anchor_order_test.go +++ b/go/internal/loadpoint/vehicle_anchor_order_test.go @@ -69,3 +69,38 @@ func TestDelayedVehicleSoCUsesMeasurementOrder(t *testing.T) { t.Fatal("a car reading measured after the manual correction cannot replace it") } } + +func TestDelayedVehicleSoCWithoutHistoryKeepsMeasurementOrder(t *testing.T) { + for _, age := range []time.Duration{2 * time.Minute, 10 * time.Minute} { + t.Run(age.String(), func(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "car", VehicleCapacityWh: 86500, PluginSoC: .60}}) + received := time.Date(2026, 10, 15, 10, 0, 0, 0, time.UTC) + now := received + m.SetNowFn(func() time.Time { return now }) + // A restart begins mid-session: no energy history before receipt. + m.Observe("car", true, 6000, 4000, true) + first, second := received.Add(-age), received.Add(-age/2) + if !m.AnchorVehicleSoCAt("car", .62, first) { + t.Fatal("qualified delayed reading refused") + } + now = now.Add(time.Minute) + m.Observe("car", true, 6000, 4500, true) + if !m.VehicleSoCSupersedes("car", second) { + t.Fatal("newer car measurement lost to raw fallback receipt time") + } + if got, fromVehicle := m.PlanningStartSoC("car", .68, second, true); !fromVehicle || math.Abs(got-.68) > 1e-9 { + t.Fatalf("unknown earlier energy changed planning SoC: %.12f, %t", got, fromVehicle) + } + if !m.AnchorVehicleSoCAt("car", .68, second) { + t.Fatal("newer delayed reading refused") + } + if st, _ := m.State("car"); math.Abs(st.CurrentSoC-.68) > 1e-9 || st.UpdatedAtMs != now.UnixMilli() { + t.Fatalf("raw fallback changed receipt time or added unknown energy: %+v", st) + } + if m.VehicleSoCSupersedes("car", first) || m.VehicleSoCSupersedes("car", second) { + t.Fatal("old or repeated cloud reading can replace the newer level") + } + }) + } +}