Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/anchor-old-vehicle-soc.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 8 additions & 9 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
116 changes: 108 additions & 8 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Timestamp delivered energy at its measurement time

When an EV driver reports delayed telemetry—where EVSample.EnergyAt or PowerAt predates the control tick—this records the computed Wh at m.now() rather than when the energy was measured. For example, if a counter measured at 12:10 arrives at 12:20 while the car SoC was observed at 12:15, deliveredAt interpolates the counter increase through 12:20 and invents energy delivered after the SoC observation, overestimating the anchored SoC and potentially firing completion early. Preserve the existing energy-timeline timestamps instead of assigning every sample its receipt time.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

} else {
lp.deliveredHistory = nil
}

if pluggedIn && powerW >= DeliveringW {
// Measured energy delivery is stronger evidence than a delayed
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions go/internal/loadpoint/loadpoint_anchor_vehicle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package loadpoint
import (
"math"
"testing"
"time"
)

// TestAnchorVehicleSoC — when a trusted vehicle BMS reading (e.g. Tesla
Expand Down Expand Up @@ -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")
}
}
32 changes: 24 additions & 8 deletions go/internal/telemetry/vehicle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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{}
}
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading