Skip to content

Commit 6337833

Browse files
committed
Fix FSD trip meter glitch attribution
Treat spurious trip-meter resets and implausible counter jumps as discontinuities instead of driven distance. Adds reset/restore cursor logic and tests so include_fields zero snap-backs do not inflate FSD or driving aggregates.
1 parent fdc3f30 commit 6337833

5 files changed

Lines changed: 257 additions & 21 deletions

File tree

internal/api/fsd/aggregate.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,16 @@ func applyCompactedObservationStats(
174174
// first in-window observation without a baseline contributes nothing and
175175
// does not open the attributable span.
176176
// - A non-negative delta is attributed in full to the local calendar day of
177-
// the LATER sample.
177+
// the LATER sample, unless it is physically implausible for the interval
178+
// (faster than maxAttributableSpeedMps) — that is a discontinuity, not
179+
// travel.
178180
// - A negative delta is a counter reset: it is recorded, and contributes
179181
// exactly zero distance. The post-reset absolute value is NOT treated as
180-
// distance travelled, because the distance accumulated between the reset
181-
// and the next emission is unknowable. A reset still opens/extends the
182-
// attributable span — the counter is demonstrably reporting.
182+
// distance travelled. If the next reading returns to the pre-reset
183+
// magnitude (include_fields zero / trip-meter glitch), the drop is
184+
// uncounted and only any excess past the pre-reset value is travel.
185+
// - A reset still opens/extends the attributable span — the counter is
186+
// demonstrably reporting.
183187
// - A sample whose timestamp does not advance is a duplicate/out-of-order
184188
// redelivery and is skipped without touching the accumulator.
185189
func accumulate(samples []Sample, start time.Time, loc *time.Location) *counterState {
@@ -197,6 +201,7 @@ func accumulate(samples []Sample, start time.Time, loc *time.Location) *counterS
197201

198202
var prev *float64
199203
var prevTS time.Time
204+
var cursor tripMeterCursor
200205

201206
for _, s := range ordered {
202207
inWindow := !s.TS.Before(start)
@@ -208,6 +213,7 @@ func accumulate(samples []Sample, start time.Time, loc *time.Location) *counterS
208213
// differencing trusted values across it could bridge a hidden reset
209214
// or mix wire units.
210215
prev = nil
216+
cursor.clear()
211217
if !inWindow {
212218
state.baselineAvailable = false
213219
}
@@ -222,6 +228,7 @@ func accumulate(samples []Sample, start time.Time, loc *time.Location) *counterS
222228
// Invalid observations are also barriers. The next valid row is a
223229
// fresh anchor, never a delta against stale pre-error state.
224230
prev = nil
231+
cursor.clear()
225232
if !inWindow {
226233
state.baselineAvailable = false
227234
}
@@ -256,14 +263,32 @@ func accumulate(samples []Sample, start time.Time, loc *time.Location) *counterS
256263
if prev != nil {
257264
state.derived = true
258265
state.perDayDerived[day] = true
259-
change := signalcounter.Compare(*prev, value)
260-
switch change.Kind {
261-
case signalcounter.ChangeReset:
266+
step := stepTripMeter(*prev, value, s.TS.Sub(prevTS), day, &cursor)
267+
switch {
268+
case step.Reset:
262269
state.resets++
263270
state.perDayResets[day]++
264-
case signalcounter.ChangeAdvanced:
265-
state.perDayMeters[day] += change.Delta
266-
state.totalMeters += change.Delta
271+
case step.Restored:
272+
if state.resets > 0 {
273+
state.resets--
274+
}
275+
if step.UndoResetDay != "" {
276+
state.perDayResets[step.UndoResetDay]--
277+
if state.perDayResets[step.UndoResetDay] < 0 {
278+
delete(state.perDayResets, step.UndoResetDay)
279+
}
280+
}
281+
if step.Delta > 0 {
282+
state.perDayMeters[day] += step.Delta
283+
state.totalMeters += step.Delta
284+
}
285+
case step.Implausible:
286+
// New baseline after a unit-mix or zero-glitch jump.
287+
default:
288+
if step.Delta > 0 {
289+
state.perDayMeters[day] += step.Delta
290+
state.totalMeters += step.Delta
291+
}
267292
}
268293
}
269294

internal/api/fsd/aggregate_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,54 @@ func TestAggregate_CounterResetContributesNoDistanceAndIsCounted(t *testing.T) {
420420
}
421421
}
422422

423+
func TestAggregate_SpuriousZeroDoesNotAttributeTripMeterAbsolute(t *testing.T) {
424+
// SelfDrivingMilesSinceReset / MilesSinceReset sometimes emit 0 on an
425+
// include_fields companion row, then snap back to the trip-meter
426+
// absolute. That used to become a ~5 000 mile "day".
427+
const fsdM = 4_913.0 * 1609.344
428+
const drivingM = 12_231.5 * 1609.344
429+
resp := Aggregate(utcParams(t, []Sample{
430+
fsdSample(t, "2026-02-28T22:00:00Z", fp(fsdM)),
431+
drivingSample(t, "2026-02-28T22:00:00Z", fp(drivingM)),
432+
fsdSample(t, "2026-03-01T09:00:00Z", fp(fsdM+1_000)),
433+
drivingSample(t, "2026-03-01T09:00:00Z", fp(drivingM+1_600)),
434+
fsdSample(t, "2026-03-01T09:00:01Z", fp(0)),
435+
drivingSample(t, "2026-03-01T09:00:01Z", fp(0)),
436+
fsdSample(t, "2026-03-01T09:00:02Z", fp(fsdM+2_000)),
437+
drivingSample(t, "2026-03-01T09:00:02Z", fp(drivingM+3_200)),
438+
}))
439+
440+
wantMeasured(t, resp.Totals.FSDDistanceM, 2_000, "total fsd (1 km before glitch + 1 km after restore)")
441+
wantMeasured(t, resp.Totals.DrivingDistanceM, 3_200, "total driving")
442+
if resp.Quality.FSDResetCount != 0 {
443+
t.Errorf("fsd reset count = %d, want 0 after spurious restore", resp.Quality.FSDResetCount)
444+
}
445+
if resp.Quality.DrivingResetCount != 0 {
446+
t.Errorf("driving reset count = %d, want 0 after spurious restore", resp.Quality.DrivingResetCount)
447+
}
448+
if !resp.Quality.ShareBasisAvailable {
449+
t.Error("a restored include_fields zero must not void the shared basis")
450+
}
451+
day := dayOf(t, resp, "2026-03-01")
452+
wantMeasured(t, day.FSDDistanceM, 2_000, "glitch day fsd")
453+
wantMeasured(t, day.DrivingDistanceM, 3_200, "glitch day driving")
454+
if day.ResetCount != 0 {
455+
t.Errorf("glitch day reset_count = %d, want 0", day.ResetCount)
456+
}
457+
}
458+
459+
func TestAggregate_ImplausibleJumpIsNotDistance(t *testing.T) {
460+
resp := Aggregate(utcParams(t, []Sample{
461+
fsdSample(t, "2026-02-28T22:00:00Z", fp(50)),
462+
fsdSample(t, "2026-03-01T09:00:00Z", fp(50+4_913*1609.344)),
463+
}))
464+
wantMeasured(t, resp.Totals.FSDDistanceM, 0, "implausible jump contributes no distance")
465+
wantMeasured(t, dayOf(t, resp, "2026-03-01").FSDDistanceM, 0, "implausible day")
466+
if resp.Quality.FSDResetCount != 0 {
467+
t.Errorf("reset count = %d, want 0 (discontinuity is not a driver reset)", resp.Quality.FSDResetCount)
468+
}
469+
}
470+
423471
func TestAggregate_IndependentResetsClampShareAtOneHundredPercent(t *testing.T) {
424472
resp := Aggregate(utcParams(t, []Sample{
425473
fsdSample(t, "2026-02-28T22:00:00Z", fp(0)),
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package fsd
2+
3+
import (
4+
"time"
5+
6+
signalcounter "github.com/ev-dev-labs/teslasync/internal/signal/counter"
7+
)
8+
9+
// maxAttributableSpeedMps is faster than any Tesla can travel. An advance
10+
// larger than this over the observation interval is a counter discontinuity
11+
// (include_fields zero, unit mix, trip-meter restore), not distance driven.
12+
const maxAttributableSpeedMps = 120.0
13+
14+
// minAdvanceInterval floors the speed check so a 0.01 mile tick on a
15+
// sub-second change-feed row remains attributable.
16+
const minAdvanceInterval = time.Second
17+
18+
// tripMeterCursor remembers the pre-drop reading of a resettable trip meter
19+
// so a later return to that magnitude can be treated as a telemetry glitch
20+
// instead of thousands of kilometres of driving.
21+
type tripMeterCursor struct {
22+
preReset *float64
23+
resetDay string
24+
}
25+
26+
func (c *tripMeterCursor) clear() {
27+
*c = tripMeterCursor{}
28+
}
29+
30+
func (c *tripMeterCursor) noteReset(pre float64, day string) {
31+
if c.preReset != nil {
32+
return
33+
}
34+
v := pre
35+
c.preReset = &v
36+
c.resetDay = day
37+
}
38+
39+
// tripMeterStep is the reset-safe classification of one consecutive pair.
40+
type tripMeterStep struct {
41+
Delta float64
42+
Reset bool
43+
Restored bool
44+
Implausible bool
45+
UndoResetDay string
46+
}
47+
48+
func stepTripMeter(prev, current float64, dt time.Duration, day string, cur *tripMeterCursor) tripMeterStep {
49+
if cur == nil {
50+
cur = &tripMeterCursor{}
51+
}
52+
change := signalcounter.Compare(prev, current)
53+
switch change.Kind {
54+
case signalcounter.ChangeReset:
55+
cur.noteReset(prev, day)
56+
return tripMeterStep{Reset: true}
57+
case signalcounter.ChangeAdvanced:
58+
if cur.preReset != nil {
59+
if restored, ok := restoreDelta(*cur.preReset, current); ok {
60+
undo := cur.resetDay
61+
cur.clear()
62+
return tripMeterStep{Delta: restored, Restored: true, UndoResetDay: undo}
63+
}
64+
}
65+
if !plausibleCounterAdvance(change.Delta, dt) {
66+
cur.clear()
67+
return tripMeterStep{Implausible: true}
68+
}
69+
cur.clear()
70+
return tripMeterStep{Delta: change.Delta}
71+
default:
72+
return tripMeterStep{}
73+
}
74+
}
75+
76+
// restoreDelta reports the distance to keep when a trip meter dropped and
77+
// then returned to (or passed) its pre-drop reading. Only the excess past
78+
// that reading is travel; the snap back from 0 is not.
79+
func restoreDelta(preReset, current float64) (float64, bool) {
80+
if preReset <= 0 {
81+
return 0, false
82+
}
83+
if current+1 < preReset {
84+
return 0, false
85+
}
86+
d := current - preReset
87+
if d < 0 {
88+
d = 0
89+
}
90+
return d, true
91+
}
92+
93+
func plausibleCounterAdvance(delta float64, dt time.Duration) bool {
94+
if delta <= 0 || !signalcounter.Valid(delta) {
95+
return true
96+
}
97+
if dt < minAdvanceInterval {
98+
dt = minAdvanceInterval
99+
}
100+
return delta <= maxAttributableSpeedMps*dt.Seconds()
101+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package fsd
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestRestoreDelta(t *testing.T) {
9+
t.Parallel()
10+
11+
if _, ok := restoreDelta(7_900_000, 7_900_000); !ok {
12+
t.Fatal("snap-back to the pre-reset reading must be a restore")
13+
}
14+
got, ok := restoreDelta(7_900_000, 7_901_000)
15+
if !ok || got != 1_000 {
16+
t.Fatalf("restore excess = (%v, %v), want (1000, true)", got, ok)
17+
}
18+
if _, ok := restoreDelta(9_000, 100); ok {
19+
t.Fatal("real post-reset climb must not look like a restore")
20+
}
21+
if _, ok := restoreDelta(0, 5_000); ok {
22+
t.Fatal("a zero baseline is not a restore")
23+
}
24+
}
25+
26+
func TestPlausibleCounterAdvance(t *testing.T) {
27+
t.Parallel()
28+
29+
if !plausibleCounterAdvance(80, 24*time.Hour) {
30+
t.Fatal("80 m over a day must be attributable")
31+
}
32+
if plausibleCounterAdvance(4_913*1609.344, 2*time.Second) {
33+
t.Fatal("thousands of miles in two seconds must be rejected")
34+
}
35+
if !plausibleCounterAdvance(16, time.Millisecond) {
36+
t.Fatal("a sub-second 0.01 mile tick must still pass the floor")
37+
}
38+
}
39+
40+
func TestStepTripMeterSpuriousZero(t *testing.T) {
41+
t.Parallel()
42+
43+
var cur tripMeterCursor
44+
reset := stepTripMeter(7_900_000, 0, time.Second, "2026-03-01", &cur)
45+
if !reset.Reset || cur.preReset == nil {
46+
t.Fatalf("drop to 0: %+v cursor=%+v", reset, cur)
47+
}
48+
restore := stepTripMeter(0, 7_901_000, time.Second, "2026-03-01", &cur)
49+
if !restore.Restored || restore.Delta != 1_000 || restore.UndoResetDay != "2026-03-01" {
50+
t.Fatalf("restore: %+v", restore)
51+
}
52+
if cur.preReset != nil {
53+
t.Fatal("cursor must clear after restore")
54+
}
55+
}

internal/api/fsd/drive_aggregate.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,37 +130,44 @@ func BuildDriveAnalytics(
130130
analytics := emptyDriveAnalytics(current, previous)
131131
var ambiguousDistance, unattributedDistance float64
132132

133+
var fsdCursor tripMeterCursor
133134
for index := 1; index < len(fsdObservations); index++ {
134135
earlier := fsdObservations[index-1]
135136
later := fsdObservations[index]
136137
if earlier.segment != later.segment {
138+
fsdCursor.clear()
137139
continue
138140
}
141+
step := stepTripMeter(
142+
earlier.value,
143+
later.value,
144+
later.at.Sub(earlier.at),
145+
"",
146+
&fsdCursor,
147+
)
139148
if later.at.Before(current.Period.StartAt) || !later.at.Before(current.Period.EndAt) {
140149
continue
141150
}
142-
143-
change := signalcounter.Compare(earlier.value, later.value)
144-
if change.Kind != signalcounter.ChangeAdvanced {
151+
if step.Delta <= 0 {
145152
continue
146153
}
147154
if earlier.at.Before(previous.Period.StartAt) {
148-
unattributedDistance += change.Delta
155+
unattributedDistance += step.Delta
149156
continue
150157
}
151158

152159
candidates := overlapSweep.overlapping(earlier.at, later.at)
153160
currentCandidates := currentPeriodStates(candidates, currentDriveIDs)
154161
switch len(candidates) {
155162
case 0:
156-
unattributedDistance += change.Delta
163+
unattributedDistance += step.Delta
157164
case 1:
158165
if len(currentCandidates) == 0 {
159-
unattributedDistance += change.Delta
166+
unattributedDistance += step.Delta
160167
continue
161168
}
162169
state := currentCandidates[0]
163-
state.uniqueDistanceM += change.Delta
170+
state.uniqueDistanceM += step.Delta
164171
if !intervalIsBoundedByDrive(
165172
state.drive,
166173
earlier.at,
@@ -173,21 +180,21 @@ func BuildDriveAnalytics(
173180
state.summary.Evidence = append(state.summary.Evidence, EvidenceInterval{
174181
StartAt: maxTime(earlier.at, state.drive.StartedAt),
175182
EndAt: minTime(later.at, driveEnd(state.drive, current.Period.EndAt)),
176-
FSDDistanceM: roundMeters(change.Delta),
183+
FSDDistanceM: roundMeters(step.Delta),
177184
Confidence: ConfidenceEstimated,
178185
Approximate: true,
179186
})
180187
}
181188
default:
182189
if len(currentCandidates) == 0 {
183-
unattributedDistance += change.Delta
190+
unattributedDistance += step.Delta
184191
continue
185192
}
186-
ambiguousDistance += change.Delta
193+
ambiguousDistance += step.Delta
187194
totalOverlap := totalOverlapDuration(candidates, earlier.at, later.at, current.Period.EndAt)
188195
for _, state := range candidates {
189196
share := proportionalDistance(
190-
change.Delta,
197+
step.Delta,
191198
overlapDuration(state.drive, earlier.at, later.at, current.Period.EndAt),
192199
totalOverlap,
193200
len(candidates),

0 commit comments

Comments
 (0)