fix: Pi trip builder drops GPS lock-in drift trips (BUG-23)
Add the server's minimum-trip filter to build_trip_summaries() — duration < 30s OR distance < 0.05km is rejected — so GPS lock-in drift no longer spawns false trips on the Pi. Also sort normalized rows by datetime (the server already orders by recorded_at ASC), fixing clock-rollback trip boundaries. Bump TRIP_BUILD_VERSION to force a Pi-side rebuild.
This commit is contained in:
+1
-1
@@ -25,7 +25,7 @@ IDLE_LOG_INTERVAL = 60
|
|||||||
GPS_LOG_DISTANCE_METERS = 8
|
GPS_LOG_DISTANCE_METERS = 8
|
||||||
TRIP_REFRESH_INTERVAL = 30
|
TRIP_REFRESH_INTERVAL = 30
|
||||||
TRIP_REBUILD_OVERLAP_ROWS = 120
|
TRIP_REBUILD_OVERLAP_ROWS = 120
|
||||||
TRIP_BUILD_VERSION = 3
|
TRIP_BUILD_VERSION = 4
|
||||||
|
|
||||||
_conn = None
|
_conn = None
|
||||||
_conn_lock = Lock()
|
_conn_lock = Lock()
|
||||||
|
|||||||
+9
-1
@@ -62,6 +62,10 @@ def normalize_tracking_rows(rows):
|
|||||||
"speed": max(0, speed),
|
"speed": max(0, speed),
|
||||||
"odometer": odometer,
|
"odometer": odometer,
|
||||||
})
|
})
|
||||||
|
# The server reads tracking_points with ORDER BY recorded_at ASC — sort the
|
||||||
|
# same way here so a clock rollback (BUG-17, pre-RTC) can't make a trip's
|
||||||
|
# start/end span a backwards time jump.
|
||||||
|
normalized.sort(key=lambda item: item["datetime"])
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@@ -220,7 +224,11 @@ def build_trip_summaries(raw_rows):
|
|||||||
duration_seconds = max(
|
duration_seconds = max(
|
||||||
0, int((end["datetime"] - start["datetime"]).total_seconds())
|
0, int((end["datetime"] - start["datetime"]).total_seconds())
|
||||||
)
|
)
|
||||||
if not any(event["obd"] for event in events) and duration_seconds < 10:
|
# Match the server's minimum-trip filter (server/modules/db.py):
|
||||||
|
# duration < 30s OR distance < 0.05km means the "trip" was GPS lock-in
|
||||||
|
# drift or a sub-50m shuffle — the car wasn't actually moving, so skip
|
||||||
|
# it. Keeps the Pi's trip count in lockstep with the server's.
|
||||||
|
if duration_seconds < 30 or distance_km < 0.05:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Speed-only rows (logged before the first GPS fix) have NULL coords.
|
# Speed-only rows (logged before the first GPS fix) have NULL coords.
|
||||||
|
|||||||
+34
-29
@@ -33,21 +33,30 @@ class TripBuilderTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_builds_gps_trip_after_sustained_motion(self):
|
def test_builds_gps_trip_after_sustained_motion(self):
|
||||||
rows = self.rows_for_route([
|
rows = self.rows_for_route([
|
||||||
(45.5000, -73.5000),
|
(45.5 + index * 0.0001, -73.5) for index in range(10)
|
||||||
(45.5001, -73.5000),
|
|
||||||
(45.5002, -73.5000),
|
|
||||||
(45.5003, -73.5000),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
trips = app_trip.build_trip_summaries(rows)
|
trips = app_trip.build_trip_summaries(rows)
|
||||||
|
|
||||||
self.assertEqual(len(trips), 1)
|
self.assertEqual(len(trips), 1)
|
||||||
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
||||||
self.assertEqual(trips[0]["end_tracking_id"], 4)
|
self.assertEqual(trips[0]["end_tracking_id"], 10)
|
||||||
self.assertGreater(trips[0]["distance_km"], 0.03)
|
self.assertGreater(trips[0]["distance_km"], 0.05)
|
||||||
self.assertGreater(trips[0]["average_speed_kmh"], 0)
|
self.assertGreater(trips[0]["average_speed_kmh"], 0)
|
||||||
self.assertGreater(trips[0]["max_speed_kmh"], 0)
|
self.assertGreater(trips[0]["max_speed_kmh"], 0)
|
||||||
|
|
||||||
|
def test_short_gps_drift_trip_is_rejected(self):
|
||||||
|
# GPS lock-in drift: < 30s and < 50m must NOT become a trip
|
||||||
|
# (mirrors server/modules/db.py's minimum-trip filter).
|
||||||
|
rows = self.rows_for_route([
|
||||||
|
(45.5000, -73.5000),
|
||||||
|
(45.5001, -73.5000),
|
||||||
|
(45.5002, -73.5000),
|
||||||
|
(45.5003, -73.5000),
|
||||||
|
])
|
||||||
|
|
||||||
|
self.assertEqual(app_trip.build_trip_summaries(rows), [])
|
||||||
|
|
||||||
def test_ignores_stationary_jitter_and_one_bad_coordinate(self):
|
def test_ignores_stationary_jitter_and_one_bad_coordinate(self):
|
||||||
rows = self.rows_for_route([
|
rows = self.rows_for_route([
|
||||||
(45.500000, -73.500000),
|
(45.500000, -73.500000),
|
||||||
@@ -61,17 +70,11 @@ class TripBuilderTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_inactivity_gap_splits_trips(self):
|
def test_inactivity_gap_splits_trips(self):
|
||||||
first = self.rows_for_route([
|
first = self.rows_for_route([
|
||||||
(45.5000, -73.5000),
|
(45.5 + index * 0.0001, -73.5) for index in range(10)
|
||||||
(45.5001, -73.5000),
|
|
||||||
(45.5002, -73.5000),
|
|
||||||
(45.5003, -73.5000),
|
|
||||||
])
|
])
|
||||||
second = self.rows_for_route([
|
second = self.rows_for_route([
|
||||||
(45.5100, -73.5100),
|
(45.51 + index * 0.0001, -73.51) for index in range(10)
|
||||||
(45.5101, -73.5100),
|
], start_id=11)
|
||||||
(45.5102, -73.5100),
|
|
||||||
(45.5103, -73.5100),
|
|
||||||
], start_id=5)
|
|
||||||
second = [
|
second = [
|
||||||
(row[0], row[1] + timedelta(minutes=10), row[2], row[3], row[4])
|
(row[0], row[1] + timedelta(minutes=10), row[2], row[3], row[4])
|
||||||
for row in second
|
for row in second
|
||||||
@@ -83,8 +86,8 @@ class TripBuilderTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_obd_speed_detects_slow_trip(self):
|
def test_obd_speed_detects_slow_trip(self):
|
||||||
rows = self.rows_for_route(
|
rows = self.rows_for_route(
|
||||||
[(45.5, -73.5), (45.5, -73.5)],
|
[(45.5 + index * 0.00006, -73.5) for index in range(10)],
|
||||||
speeds=[0, 5],
|
speeds=[0, 5, 5, 5, 5, 5, 5, 5, 5, 5],
|
||||||
)
|
)
|
||||||
|
|
||||||
trips = app_trip.build_trip_summaries(rows)
|
trips = app_trip.build_trip_summaries(rows)
|
||||||
@@ -105,15 +108,15 @@ class TripBuilderTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_combined_obd_and_gps_motion_creates_one_trip(self):
|
def test_combined_obd_and_gps_motion_creates_one_trip(self):
|
||||||
rows = self.rows_for_route(
|
rows = self.rows_for_route(
|
||||||
[(45.5 + index * 0.0001, -73.5) for index in range(5)],
|
[(45.5 + index * 0.0001, -73.5) for index in range(10)],
|
||||||
speeds=[20] * 5,
|
speeds=[20] * 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
trips = app_trip.build_trip_summaries(rows)
|
trips = app_trip.build_trip_summaries(rows)
|
||||||
|
|
||||||
self.assertEqual(len(trips), 1)
|
self.assertEqual(len(trips), 1)
|
||||||
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
||||||
self.assertEqual(trips[0]["end_tracking_id"], 5)
|
self.assertEqual(trips[0]["end_tracking_id"], 10)
|
||||||
|
|
||||||
def test_speed_only_rows_without_any_gps_are_skipped(self):
|
def test_speed_only_rows_without_any_gps_are_skipped(self):
|
||||||
started = datetime(2026, 1, 1, 8)
|
started = datetime(2026, 1, 1, 8)
|
||||||
@@ -133,28 +136,30 @@ class TripBuilderTest(unittest.TestCase):
|
|||||||
rows = [
|
rows = [
|
||||||
(1, started, None, None, 30),
|
(1, started, None, None, 30),
|
||||||
(2, started + timedelta(seconds=5), None, None, 30),
|
(2, started + timedelta(seconds=5), None, None, 30),
|
||||||
(3, started + timedelta(seconds=10), 45.5000, -73.5000, 30),
|
] + [
|
||||||
(4, started + timedelta(seconds=15), 45.5001, -73.5000, 30),
|
(3 + index,
|
||||||
(5, started + timedelta(seconds=20), 45.5002, -73.5000, 30),
|
started + timedelta(seconds=10 + index * 5),
|
||||||
|
45.5 + index * 0.0001, -73.5, 30)
|
||||||
|
for index in range(8)
|
||||||
]
|
]
|
||||||
|
|
||||||
trips = app_trip.build_trip_summaries(rows)
|
trips = app_trip.build_trip_summaries(rows)
|
||||||
|
|
||||||
self.assertEqual(len(trips), 1)
|
self.assertEqual(len(trips), 1)
|
||||||
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
self.assertEqual(trips[0]["start_tracking_id"], 1)
|
||||||
self.assertEqual(trips[0]["end_tracking_id"], 5)
|
self.assertEqual(trips[0]["end_tracking_id"], 10)
|
||||||
self.assertEqual(trips[0]["start_gpsx"], 45.5)
|
self.assertEqual(trips[0]["start_gpsx"], 45.5)
|
||||||
self.assertEqual(trips[0]["start_gpsy"], -73.5)
|
self.assertEqual(trips[0]["start_gpsy"], -73.5)
|
||||||
|
|
||||||
def test_clock_rollback_does_not_merge_motion_episodes(self):
|
def test_clock_rollback_does_not_merge_motion_episodes(self):
|
||||||
first = self.rows_for_route(
|
first = self.rows_for_route(
|
||||||
[(45.5, -73.5), (45.5, -73.5)],
|
[(45.5 + index * 0.0001, -73.5) for index in range(10)],
|
||||||
speeds=[0, 5],
|
speeds=[5] * 10,
|
||||||
)
|
)
|
||||||
second = self.rows_for_route(
|
second = self.rows_for_route(
|
||||||
[(45.51, -73.51), (45.51, -73.51)],
|
[(45.51 + index * 0.0001, -73.51) for index in range(10)],
|
||||||
speeds=[0, 5],
|
speeds=[5] * 10,
|
||||||
start_id=3,
|
start_id=11,
|
||||||
)
|
)
|
||||||
second = [
|
second = [
|
||||||
(row[0], row[1] - timedelta(hours=1), row[2], row[3], row[4])
|
(row[0], row[1] - timedelta(hours=1), row[2], row[3], row[4])
|
||||||
|
|||||||
Reference in New Issue
Block a user