diff --git a/modules/app_sql.py b/modules/app_sql.py index c111909..bffd0ce 100644 --- a/modules/app_sql.py +++ b/modules/app_sql.py @@ -25,7 +25,7 @@ IDLE_LOG_INTERVAL = 60 GPS_LOG_DISTANCE_METERS = 8 TRIP_REFRESH_INTERVAL = 30 TRIP_REBUILD_OVERLAP_ROWS = 120 -TRIP_BUILD_VERSION = 3 +TRIP_BUILD_VERSION = 4 _conn = None _conn_lock = Lock() diff --git a/modules/app_trip.py b/modules/app_trip.py index 0244256..9b01d40 100644 --- a/modules/app_trip.py +++ b/modules/app_trip.py @@ -62,6 +62,10 @@ def normalize_tracking_rows(rows): "speed": max(0, speed), "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 @@ -220,7 +224,11 @@ def build_trip_summaries(raw_rows): duration_seconds = max( 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 # Speed-only rows (logged before the first GPS fix) have NULL coords. diff --git a/tests/test_app_trip.py b/tests/test_app_trip.py index ba6995b..6e45cba 100644 --- a/tests/test_app_trip.py +++ b/tests/test_app_trip.py @@ -33,21 +33,30 @@ class TripBuilderTest(unittest.TestCase): def test_builds_gps_trip_after_sustained_motion(self): rows = self.rows_for_route([ - (45.5000, -73.5000), - (45.5001, -73.5000), - (45.5002, -73.5000), - (45.5003, -73.5000), + (45.5 + index * 0.0001, -73.5) for index in range(10) ]) trips = app_trip.build_trip_summaries(rows) self.assertEqual(len(trips), 1) self.assertEqual(trips[0]["start_tracking_id"], 1) - self.assertEqual(trips[0]["end_tracking_id"], 4) - self.assertGreater(trips[0]["distance_km"], 0.03) + self.assertEqual(trips[0]["end_tracking_id"], 10) + self.assertGreater(trips[0]["distance_km"], 0.05) self.assertGreater(trips[0]["average_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): rows = self.rows_for_route([ (45.500000, -73.500000), @@ -61,17 +70,11 @@ class TripBuilderTest(unittest.TestCase): def test_inactivity_gap_splits_trips(self): first = self.rows_for_route([ - (45.5000, -73.5000), - (45.5001, -73.5000), - (45.5002, -73.5000), - (45.5003, -73.5000), + (45.5 + index * 0.0001, -73.5) for index in range(10) ]) second = self.rows_for_route([ - (45.5100, -73.5100), - (45.5101, -73.5100), - (45.5102, -73.5100), - (45.5103, -73.5100), - ], start_id=5) + (45.51 + index * 0.0001, -73.51) for index in range(10) + ], start_id=11) second = [ (row[0], row[1] + timedelta(minutes=10), row[2], row[3], row[4]) for row in second @@ -83,8 +86,8 @@ class TripBuilderTest(unittest.TestCase): def test_obd_speed_detects_slow_trip(self): rows = self.rows_for_route( - [(45.5, -73.5), (45.5, -73.5)], - speeds=[0, 5], + [(45.5 + index * 0.00006, -73.5) for index in range(10)], + speeds=[0, 5, 5, 5, 5, 5, 5, 5, 5, 5], ) 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): rows = self.rows_for_route( - [(45.5 + index * 0.0001, -73.5) for index in range(5)], - speeds=[20] * 5, + [(45.5 + index * 0.0001, -73.5) for index in range(10)], + speeds=[20] * 10, ) trips = app_trip.build_trip_summaries(rows) self.assertEqual(len(trips), 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): started = datetime(2026, 1, 1, 8) @@ -133,28 +136,30 @@ class TripBuilderTest(unittest.TestCase): rows = [ (1, started, 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), - (5, started + timedelta(seconds=20), 45.5002, -73.5000, 30), + ] + [ + (3 + index, + 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) self.assertEqual(len(trips), 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_gpsy"], -73.5) def test_clock_rollback_does_not_merge_motion_episodes(self): first = self.rows_for_route( - [(45.5, -73.5), (45.5, -73.5)], - speeds=[0, 5], + [(45.5 + index * 0.0001, -73.5) for index in range(10)], + speeds=[5] * 10, ) second = self.rows_for_route( - [(45.51, -73.51), (45.51, -73.51)], - speeds=[0, 5], - start_id=3, + [(45.51 + index * 0.0001, -73.51) for index in range(10)], + speeds=[5] * 10, + start_id=11, ) second = [ (row[0], row[1] - timedelta(hours=1), row[2], row[3], row[4])