fix(stats): reject sub-threshold gps drift
This commit is contained in:
@@ -2128,7 +2128,7 @@ func TestWriterDuplicateYutongOdometerStillEntersGPSFallback(t *testing.T) {
|
||||
EventTimeMS: eventTime.UnixMilli(),
|
||||
Fields: map[string]any{
|
||||
"yutong_mqtt.data.total_mileage": 24245000,
|
||||
"yutong_mqtt.data.longitude": 114.435672,
|
||||
"yutong_mqtt.data.longitude": 114.436672,
|
||||
"yutong_mqtt.data.latitude": 30.645438,
|
||||
"yutong_mqtt.data.meter_speed": 18,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
gpsCoordinateSourceSuffix = "#GPS_COORDINATE"
|
||||
gpsMaxSegmentGap = 10 * time.Minute
|
||||
gpsMaxImpliedSpeedKMH = 220.0
|
||||
gpsMinimumDailyDistanceKM = 0.1
|
||||
)
|
||||
|
||||
const GPSMileageStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_gps_mileage_state (
|
||||
@@ -238,7 +239,7 @@ func AccumulateGPSMileage(ctx context.Context, exec Execer, point GPSMileagePoin
|
||||
_ = tx.Rollback()
|
||||
return GPSMileageState{}, false, err
|
||||
}
|
||||
if state.UsableSegmentCount == 0 {
|
||||
if !GPSMileageCandidateEligible(state.DailyMileageKM, state.UsableSegmentCount) {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return GPSMileageState{}, false, err
|
||||
}
|
||||
@@ -258,6 +259,16 @@ func AccumulateGPSMileage(ctx context.Context, exec Execer, point GPSMileagePoin
|
||||
return state, true, nil
|
||||
}
|
||||
|
||||
// GPSMileageCandidateEligible keeps sub-100-metre stationary drift and
|
||||
// momentary speed noise in the durable accumulation state without exposing it
|
||||
// as a business daily-mileage candidate. Real movement continues accumulating
|
||||
// and becomes eligible once the evidence crosses the minimum distance.
|
||||
func GPSMileageCandidateEligible(distanceKM float64, usableSegmentCount int64) bool {
|
||||
return usableSegmentCount > 0 &&
|
||||
isFiniteNonNegative(distanceKM) &&
|
||||
distanceKM >= gpsMinimumDailyDistanceKM
|
||||
}
|
||||
|
||||
// AccumulateJT808GPSMileage remains as a compatibility wrapper for callers and
|
||||
// operational tooling compiled against the original JT808-only fallback.
|
||||
func AccumulateJT808GPSMileage(ctx context.Context, exec Execer, point GPSMileagePoint) (GPSMileageState, bool, error) {
|
||||
|
||||
@@ -151,6 +151,18 @@ func TestWriterThrottlesHighFrequencyGPSMileageStateWrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPSMileageCandidateEligibleRejectsStationaryDrift(t *testing.T) {
|
||||
if GPSMileageCandidateEligible(0.099999, 10) {
|
||||
t.Fatal("sub-100-metre GPS drift must not become a daily-mileage candidate")
|
||||
}
|
||||
if GPSMileageCandidateEligible(0.2, 0) {
|
||||
t.Fatal("distance without a usable segment must not become a candidate")
|
||||
}
|
||||
if !GPSMileageCandidateEligible(0.1, 1) {
|
||||
t.Fatal("100 metres with a usable segment should become eligible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulateJT808GPSMileagePersistsEstimateAndProjects(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -250,3 +262,47 @@ func TestAccumulateJT808GPSMileageNeedsUsableSegmentBeforeProjection(t *testing.
|
||||
t.Fatalf("unmet SQL expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccumulateGPSMileageKeepsSubThresholdDistanceAsStateOnly(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
start := time.Date(2026, 7, 20, 0, 14, 56, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
point := GPSMileagePoint{
|
||||
VIN: "LMRKH9ACXR1004130",
|
||||
StatDate: "2026-07-20",
|
||||
Protocol: envelope.ProtocolYutongMQTT,
|
||||
SourceKey: "YUTONG_MQTT:LMRKH9ACXR1004130@PLATFORM:yutong",
|
||||
SourceIP: "mqtt",
|
||||
EventID: "evt-small-movement",
|
||||
EventTime: start.Add(time.Second),
|
||||
Longitude: 114.435573,
|
||||
Latitude: 30.645503,
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT first_event_time, latest_event_time").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"first_event_time", "latest_event_time", "latest_event_id",
|
||||
"latest_longitude", "latest_latitude", "daily_mileage_km",
|
||||
"point_count", "usable_segment_count", "bad_jump_count",
|
||||
"long_gap_count", "out_of_order_count",
|
||||
}).AddRow(start, start, "evt-before", 114.435572, 30.645503, 0, 1, 0, 0, 0, 0))
|
||||
mock.ExpectExec("UPDATE vehicle_daily_gps_mileage_state").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
state, recovered, err := AccumulateGPSMileage(context.Background(), db, point)
|
||||
if err != nil {
|
||||
t.Fatalf("AccumulateGPSMileage() error = %v", err)
|
||||
}
|
||||
if recovered || state.DailyMileageKM <= 0 || state.DailyMileageKM >= gpsMinimumDailyDistanceKM {
|
||||
t.Fatalf("sub-threshold state = %+v recovered=%v", state, recovered)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet SQL expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user