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/vehicle-soc-measurement-order.md
Original file line number Diff line number Diff line change
@@ -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. Keep the measurement time when restart or trimmed history requires using the raw reading.
28 changes: 15 additions & 13 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -1150,17 +1150,19 @@ 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.
lp.socConfirmedAt = observedAt
Comment on lines +1162 to +1164

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 Preserve source time through the unknown-history fallback

After a mid-session restart, a delayed measurement from before the first retained energy sample makes deliveredAt fail, so go/cmd/ftw/main.go:2986-2987 falls back to AnchorVehicleSoC, which passes a zero timestamp and records the receipt time instead. A later cloud reading that was measured after the first but before that receipt is then rejected by VehicleSoCSupersedes, leaving the ordering bug in place for cold-start/recovery sessions; preserve the original observedAt even when anchoring must fall back to the current energy value.

AGENTS.md reference: AGENTS.md:L24-L25

Useful? React with 👍 / 👎.

}
lp.vehicleAnchored = true
if lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && !lp.completionNotified {
lp.completionNotified = true
Expand Down
30 changes: 21 additions & 9 deletions go/internal/loadpoint/loadpoint_anchor_vehicle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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)
}
}
106 changes: 106 additions & 0 deletions go/internal/loadpoint/vehicle_anchor_order_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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")
}
}

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")
}
})
}
}
Loading