From f585074c35f7606317159331521e5a2be48a66b7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 2 Oct 2026 15:28:07 +0200 Subject: [PATCH] fix(ev): anchor older car SoC with energy delivered since A car SoC was used only while under five minutes old, and each tick re-based the estimate as if the reading were current. Cloud sources such as the VW Group EU Data Act portal report every 15 minutes, so control rarely saw the car, and the per-tick re-anchor dropped the energy delivered after the reading. The loadpoint now keeps a short per-session history of delivered Wh (one sample per 30 s, two hours) and anchors a reading at the time it arrived. PickVehicleForAnchor accepts readings up to an hour old for this path only. Readings from before plug-in (beyond five minutes), or whose energy offset is unknown after a restart or trim, do not anchor. VehicleMaxAge keeps its meaning for display, MPC's direct use and goal completion. Co-Authored-By: Claude Opus 5.5 --- .changeset/anchor-old-vehicle-soc.md | 5 + go/cmd/ftw/main.go | 17 ++- go/internal/loadpoint/loadpoint.go | 116 ++++++++++++++++-- .../loadpoint_anchor_vehicle_test.go | 87 +++++++++++++ go/internal/telemetry/vehicle.go | 32 +++-- go/internal/telemetry/vehicle_test.go | 29 +++++ 6 files changed, 261 insertions(+), 25 deletions(-) create mode 100644 .changeset/anchor-old-vehicle-soc.md diff --git a/.changeset/anchor-old-vehicle-soc.md b/.changeset/anchor-old-vehicle-soc.md new file mode 100644 index 000000000..a8d59cdd9 --- /dev/null +++ b/.changeset/anchor-old-vehicle-soc.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A car reading up to an hour old now corrects FTW's charge estimate. FTW anchors the reading at the time it arrived and adds the energy delivered since then. Cloud sources such as the VW Group portal, which reports every 15 minutes, now keep the estimate close to the car's level. Before, FTW used a car reading only in its first five minutes. It also pinned the estimate to the latest reading and dropped the energy delivered after it. A reading from before plug-in, or from before a restart, does not anchor. Display and goal completion still treat readings older than five minutes as old. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index c991564f9..d440e7f6b 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2893,22 +2893,21 @@ func main() { // plug-in-anchor + delivered-Wh estimate; when a vehicle driver // (TeslaBLEProxy etc.) is online and matched, its SoC is ground // truth. Runs after Tick's Observe so the per-tick re-anchor - // wins over that tick's inference. Same picker the MPC spec and - // api.go's loadpoint decoration use, so all three agree on which - // vehicle is "the one"; we additionally require !Stale so a - // driver serving last-known cache (car asleep) can't pin the - // dashboard to a stale value — inference takes over until fresh - // BMS data returns. + // wins over that tick's inference. The reading is anchored at + // the time it arrived, and energy delivered since then moves + // the estimate on, so a cloud SoC up to VehicleAnchorMaxAge old + // still helps. Driver-marked stale values and replays of a + // cached value never anchor. for _, st := range lpMgr.States() { if !st.PluggedIn { continue } delivering := st.CurrentPowerW > loadpoint.DeliveringW - pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()) - if pick.Driver == "" || pick.Stale { + pick := telemetry.PickVehicleForAnchor(tel, delivering, time.Now()) + if pick.Driver == "" { continue } - lpMgr.AnchorVehicleSoC(st.ID, pick.SoC) + lpMgr.AnchorVehicleSoCAt(st.ID, pick.SoC, pick.UpdatedAt) } if !freshness.Allowed() { diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 85fdccd49..a19e8a2b6 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -360,11 +360,14 @@ type loadpointRuntime struct { completionNotified bool Config - pluggedIn bool - connectionObservedAt time.Time - currentSoC float64 - currentPowerW float64 - deliveredWhSession float64 + pluggedIn bool + connectionObservedAt time.Time + currentSoC float64 + currentPowerW float64 + deliveredWhSession float64 + // deliveredHistory samples deliveredWhSession during the session, so + // a car reading can be anchored at the time it was measured. + deliveredHistory []deliveredSample finishAtVehicleLimit bool finishGoalCompleted bool finishGoalSavedCompleted bool @@ -569,6 +572,7 @@ func (m *Manager) Load(cfgs []Config) { lp.currentSoC = existing.currentSoC lp.currentPowerW = existing.currentPowerW lp.deliveredWhSession = existing.deliveredWhSession + lp.deliveredHistory = existing.deliveredHistory lp.energy = existing.energy lp.powerAt = existing.powerAt lp.powerWindow = existing.powerWindow @@ -754,6 +758,7 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.sessionPluginSoC = anchor lp.socConfirmed = false lp.completionNotified = false + lp.deliveredHistory = nil lp.notRequestingSince = time.Time{} lp.chargingDeclined = false } @@ -773,6 +778,11 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.pluggedIn = pluggedIn lp.currentPowerW = powerW lp.deliveredWhSession = deliveredWh + if pluggedIn { + lp.recordDelivered(now, deliveredWh) + } else { + lp.deliveredHistory = nil + } if pluggedIn && powerW >= DeliveringW { // Measured energy delivery is stronger evidence than a delayed @@ -1078,6 +1088,14 @@ func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { // moves the anchor. If anchoring stops, the last BMS value stays. // False when the id is unknown or the car is unplugged. 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. +func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time.Time) bool { m.sessionMu.Lock() m.mu.Lock() var completion *events.ChargingSessionComplete @@ -1097,26 +1115,108 @@ func (m *Manager) AnchorVehicleSoC(id string, socPct float64) bool { if !lp.pluggedIn || !finite(socPct) || socPct < 0 || socPct > 1 { return false } - if lp.targetSoC > 0 && socPct >= lp.targetSoC && !lp.completionNotified { + 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 + } + } + reanchorSoCAtLocked(lp, socPct, deliveredAt) + if lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && !lp.completionNotified { lp.completionNotified = true completion = &events.ChargingSessionComplete{LoadpointID: id, KWh: lp.deliveredWhSession / 1000, At: m.now()} } - reanchorSoCLocked(lp, socPct) return true } +type deliveredSample struct { + at time.Time + wh float64 +} + +const ( + // deliveredHistoryKeep bounds how old a car reading may be and still + // be anchored. Telemetry's VehicleAnchorMaxAge stays within it. + deliveredHistoryKeep = 2 * time.Hour + deliveredHistoryStep = 30 * time.Second + // anchorBeforePlugSlack accepts a reading taken just before plug-in, + // as live car drivers report a moment before the charger does. + anchorBeforePlugSlack = 5 * time.Minute +) + +// recordDelivered appends at most one sample per step and drops samples +// older than deliveredHistoryKeep. A counter that goes backwards starts a +// new history, since earlier samples no longer share its origin. +func (lp *loadpointRuntime) recordDelivered(now time.Time, wh float64) { + h := lp.deliveredHistory + if n := len(h); n > 0 { + last := h[n-1] + if wh < last.wh || now.Before(last.at) { + h = h[:0] + } else if now.Sub(last.at) < deliveredHistoryStep { + return + } + } + h = append(h, deliveredSample{at: now, wh: wh}) + cut := 0 + for cut < len(h)-1 && now.Sub(h[cut+1].at) >= deliveredHistoryKeep { + cut++ + } + if cut > 0 { + h = append(h[:0], h[cut:]...) + } + lp.deliveredHistory = h +} + +// deliveredAt returns the session energy at t, interpolated between +// samples. Before the first sample the energy is known only if that +// sample is still zero: nothing had been delivered yet. +func (lp *loadpointRuntime) deliveredAt(t time.Time) (float64, bool) { + h := lp.deliveredHistory + if len(h) == 0 { + return 0, false + } + if t.Before(h[0].at) { + return h[0].wh, h[0].wh == 0 + } + last := h[len(h)-1] + if !t.Before(last.at) { + return lp.deliveredWhSession, true + } + for i := 1; i < len(h); i++ { + if t.After(h[i].at) { + continue + } + a, b := h[i-1], h[i] + f := float64(t.Sub(a.at)) / float64(b.at.Sub(a.at)) + return a.wh + f*(b.wh-a.wh), true + } + return lp.deliveredWhSession, true +} + // reanchorSoCLocked re-bases the session anchor so the CURRENT estimate // equals socPct, then recomputes current_soc from it. Caller must hold // m.mu and have verified the loadpoint is plugged in. Shared by the // manual (SetCurrentSoC) and automatic (AnchorVehicleSoC) correction // paths so they stay arithmetically identical. func reanchorSoCLocked(lp *loadpointRuntime, soc float64) { + reanchorSoCAtLocked(lp, soc, lp.deliveredWhSession) +} + +// reanchorSoCAtLocked sets the anchor so the estimate equals soc when the +// session had delivered deliveredWh. +func reanchorSoCAtLocked(lp *loadpointRuntime, soc, deliveredWh float64) { soc = units.ClampFraction(soc) lp.socConfirmed = true // Re-anchor: new_anchor + delivered/capacity == soc. delivered := 0.0 if lp.VehicleCapacityWh > 0 { - delivered = lp.deliveredWhSession * DefaultChargeEfficiency / lp.VehicleCapacityWh + delivered = deliveredWh * DefaultChargeEfficiency / lp.VehicleCapacityWh } // The offset may be negative when the corrected level is below the // energy already delivered. Clamp the resulting level, not the offset. diff --git a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go index f054d9014..95fc10c2a 100644 --- a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go +++ b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go @@ -3,6 +3,7 @@ package loadpoint import ( "math" "testing" + "time" ) // TestAnchorVehicleSoC — when a trusted vehicle BMS reading (e.g. Tesla @@ -76,3 +77,89 @@ func TestAnchorVehicleSoCRejectsUnpluggedAndUnknown(t *testing.T) { t.Error("should reject AnchorVehicleSoC after unplug") } } + +// A cloud SoC arrives late. Anchoring it at its observation time keeps the +// energy delivered since then, instead of pinning the estimate to the old +// value. +func TestAnchorVehicleSoCAtAddsEnergySinceObservation(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 }) + for i, wh := range []float64{0, 1000, 2000, 3000, 4000, 5000, 6000, 7000} { + now = t0.Add(time.Duration(i) * 5 * time.Minute) + m.Observe("a", true, 11000, wh, true) + } + // The car read 50 % at t0+10 min, when 2000 Wh had been delivered. + observed := t0.Add(10 * time.Minute) + want := 0.50 + 5000*DefaultChargeEfficiency/60000 + for range 3 { + if !m.AnchorVehicleSoCAt("a", 0.50, observed) { + t.Fatal("anchor refused") + } + if st, _ := m.State("a"); math.Abs(st.CurrentSoC-want) > 1e-9 { + t.Fatalf("SoC %.4f, want %.4f", st.CurrentSoC, want) + } + } + // Between samples the energy is interpolated. + if !m.AnchorVehicleSoCAt("a", 0.50, t0.Add(12*time.Minute+30*time.Second)) { + t.Fatal("anchor refused") + } + if st, _ := m.State("a"); math.Abs(st.CurrentSoC-(0.50+4500*DefaultChargeEfficiency/60000)) > 1e-9 { + t.Fatalf("interpolated SoC %.4f", st.CurrentSoC) + } + // Re-anchoring the same reading as energy grows moves the estimate on. + now = now.Add(5 * time.Minute) + m.Observe("a", true, 11000, 8000, true) + m.AnchorVehicleSoCAt("a", 0.50, observed) + if st, _ := m.State("a"); math.Abs(st.CurrentSoC-(want+1000*DefaultChargeEfficiency/60000)) > 1e-9 { + t.Fatalf("estimate pinned to the reading: %.4f", st.CurrentSoC) + } +} + +func TestAnchorVehicleSoCAtBeforePlugIn(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, 0, 0, true) + 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(-time.Minute)) { + t.Fatal("a reading just before plug-in should anchor") + } + if st, _ := m.State("a"); math.Abs(st.CurrentSoC-(0.60+1500*DefaultChargeEfficiency/60000)) > 1e-9 { + t.Fatalf("SoC %.4f", st.CurrentSoC) + } +} + +// 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) { + 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) { + t.Fatal("a reading at the first sample should anchor") + } + // Samples older than the kept window are trimmed. + for i := 1; i <= 30; i++ { + 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") + } +} diff --git a/go/internal/telemetry/vehicle.go b/go/internal/telemetry/vehicle.go index 9714fb9ef..bb49ca6c5 100644 --- a/go/internal/telemetry/vehicle.go +++ b/go/internal/telemetry/vehicle.go @@ -17,6 +17,12 @@ import ( // would mean acting on a value that no longer reflects reality. const VehicleMaxAge = 5 * time.Minute +// VehicleAnchorMaxAge bounds how old a car reading may be and still anchor +// a loadpoint's SoC estimate. The loadpoint adds the energy delivered since +// the reading, so age adds only metering error. Cloud sources such as the +// VW Group portal report every 15 minutes. +const VehicleAnchorMaxAge = time.Hour + // VehiclePick is the "best matching" DerVehicle reading for a loadpoint: // the one most likely to be the car physically connected right now. // Empty Driver means "no usable reading" — the caller should fall back @@ -77,7 +83,7 @@ func VehicleConnectedRank(chargingState string) int { // Lives in telemetry/ rather than api/ or cmd/ because both packages // need it and the dependency direction otherwise cycles. func PickBestVehicle(s *Store, now time.Time) VehiclePick { - return pickBestVehicle(s, 0, now, false) + return pickBestVehicle(s, 0, now, VehicleMaxAge, false) } // PickBestVehicleForLoadpoint adds connection-evidence gating: when @@ -101,7 +107,17 @@ func PickBestVehicleForLoadpoint(s *Store, lpDeliveringPower bool, now time.Time // loadpoint is at 11 kW is definitely not the connected one. minRank = 3 } - return pickBestVehicle(s, minRank, now, false) + return pickBestVehicle(s, minRank, now, VehicleMaxAge, false) +} + +// PickVehicleForAnchor applies the loadpoint gates with VehicleAnchorMaxAge. +// Its SoC is a past observation: anchor it at UpdatedAt, never as now. +func PickVehicleForAnchor(s *Store, lpDeliveringPower bool, now time.Time) VehiclePick { + minRank := 0 + if lpDeliveringPower { + minRank = 3 + } + return pickBestVehicle(s, minRank, now, VehicleAnchorMaxAge, false) } // PickBestVehicleForDisplay retains an otherwise valid last-known vehicle @@ -113,7 +129,7 @@ func PickBestVehicleForDisplay(s *Store, lpDeliveringPower bool, now time.Time) if lpDeliveringPower { minRank = 3 } - return pickBestVehicle(s, minRank, now, true) + return pickBestVehicle(s, minRank, now, VehicleMaxAge, true) } // PickVehicleForCompletion requires one vehicle source. Rank and freshness @@ -123,10 +139,10 @@ func PickVehicleForCompletion(s *Store, now time.Time) VehiclePick { if s == nil || len(s.ReadingsByType(DerVehicle)) != 1 { return VehiclePick{} } - return pickBestVehicle(s, 1, now, false) + return pickBestVehicle(s, 1, now, VehicleMaxAge, false) } -func pickBestVehicle(s *Store, minRank int, now time.Time, allowAgeStale bool) VehiclePick { +func pickBestVehicle(s *Store, minRank int, now time.Time, maxAge time.Duration, allowAgeStale bool) VehiclePick { if s == nil { return VehiclePick{} } @@ -152,8 +168,8 @@ func pickBestVehicle(s *Store, minRank int, now time.Time, allowAgeStale bool) V if socUpdatedAt.IsZero() { continue } - ageStale := now.Sub(socUpdatedAt) > VehicleMaxAge - if ageStale && !allowAgeStale { + age := now.Sub(socUpdatedAt) + if age > maxAge && !allowAgeStale { // Reading is older than we're willing to trust as ground // truth — driver probably stopped publishing. Skip rather // than risk acting on a stale SoC. @@ -185,7 +201,7 @@ func pickBestVehicle(s *Store, minRank int, now time.Time, allowAgeStale bool) V SoC: soc, ChargeLimit: limit, ChargingState: meta.ChargingState, - Stale: ageStale || meta.Stale || cachedDisplay, + Stale: age > VehicleMaxAge || meta.Stale || cachedDisplay, UpdatedAt: socUpdatedAt, } bestRank = rank diff --git a/go/internal/telemetry/vehicle_test.go b/go/internal/telemetry/vehicle_test.go index 95c81767b..319a40474 100644 --- a/go/internal/telemetry/vehicle_test.go +++ b/go/internal/telemetry/vehicle_test.go @@ -335,3 +335,32 @@ func TestCachedVehicleDisplayRequiresValidSourceAgeAndSoC(t *testing.T) { } } } + +func TestPickVehicleForAnchorAcceptsCloudCadence(t *testing.T) { + s := NewStore() + pushVehicle(t, s, "cloud", 0.50, 0.80, "Stopped", false, 30*time.Minute) + + if pick := PickBestVehicleForLoadpoint(s, false, time.Now()); pick.Driver != "" { + t.Fatalf("30-minute-old SoC is not live: %+v", pick) + } + pick := PickVehicleForAnchor(s, false, time.Now()) + if pick.Driver != "cloud" || pick.SoC != 0.50 || !pick.Stale { + t.Fatalf("anchor pick: %+v", pick) + } + if age := time.Since(pick.UpdatedAt); age < 29*time.Minute { + t.Fatalf("anchor pick must keep the observation time, age %v", age) + } +} + +func TestPickVehicleForAnchorRejectsTooOldAndDriverMarkedStale(t *testing.T) { + s := NewStore() + pushVehicle(t, s, "old", 0.50, 0.80, "Stopped", false, VehicleAnchorMaxAge+time.Minute) + if pick := PickVehicleForAnchor(s, false, time.Now()); pick.Driver != "" { + t.Fatalf("reading past VehicleAnchorMaxAge: %+v", pick) + } + s = NewStore() + pushVehicle(t, s, "marked", 0.50, 0.80, "Stopped", true, time.Minute) + if pick := PickVehicleForAnchor(s, false, time.Now()); pick.Driver != "" { + t.Fatalf("driver-marked stale reading: %+v", pick) + } +}