From 96fb8971eb844d1d6f6617b70020eac3172b9385 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 11 Aug 2026 02:27:17 -0400 Subject: [PATCH] fix(phase26): server trip detection mirrors Pi motion-aware logic (BUG-04) --- server/modules/db.py | 126 ++++++++++++++++++++++++++++++------------- 1 file changed, 88 insertions(+), 38 deletions(-) diff --git a/server/modules/db.py b/server/modules/db.py index e2a45c8..34e41e6 100644 --- a/server/modules/db.py +++ b/server/modules/db.py @@ -280,7 +280,19 @@ _TRIP_GAP_S = 300 # 5 minutes def compute_trips_for_device(device_id): - """Recompute trip boundaries from tracking_points and upsert into trips table.""" + """Recompute trip boundaries from tracking_points and upsert into trips table. + + Mirrors the Pi's motion-aware trip-detection logic (modules/app_trip.py): + OBD wheel speed > 1 km/h is the primary motion signal. GPS displacement + >= 8 m per step (3 consecutive) is the fallback. A 5-minute gap between + motion events starts a new trip. This prevents parked idle rows from + merging two separate drives into one. + """ + INACTIVITY_SECONDS = 5 * 60 + GPS_MOTION_METERS = 8 + MIN_GPS_MOTION_SAMPLES = 3 + MAX_GPS_SAMPLE_GAP_SECONDS = 30 + with get_cursor() as cur: cur.execute( """ @@ -293,59 +305,97 @@ def compute_trips_for_device(device_id): ) rows = cur.fetchall() - if not rows: + if len(rows) < 2: return 0 import math - def haversine(lat1, lon1, lat2, lon2): + def haversine_km(lat1, lon1, lat2, lon2): R = 6371.0 dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) - a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2 + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) + * math.sin(dlon / 2) ** 2) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - # Split into segments by gap - segments = [] - current = [rows[0]] - for prev, curr in zip(rows, rows[1:]): - gap = (curr[0] - prev[0]).total_seconds() - if gap > _TRIP_GAP_S: - segments.append(current) - current = [curr] - else: - current.append(curr) - segments.append(current) + # Detect motion events per step + obd_motion = [] # indices where OBD speed > 1 km/h + gps_motion = [] # indices where GPS displacement >= 8 m - # Build trip records + for i in range(1, len(rows)): + prev = rows[i - 1] + curr = rows[i] + gap = (curr[0] - prev[0]).total_seconds() + speed = curr[3] or 0 + has_obd = speed > 1 + has_gps = False + if 0 < gap <= MAX_GPS_SAMPLE_GAP_SECONDS: + d = haversine_km(prev[1], prev[2], curr[1], curr[2]) + if d * 1000 >= GPS_MOTION_METERS: + has_gps = True + if has_obd: + obd_motion.append(i) + if has_gps: + gps_motion.append(i) + + # Qualify GPS motion: need 3+ consecutive within 30s gap + qualified_gps = set() + seq = [] + for idx in gps_motion: + if not seq or (rows[idx][0] - rows[seq[-1]][0]).total_seconds() > MAX_GPS_SAMPLE_GAP_SECONDS: + seq = [idx] + else: + seq.append(idx) + if len(seq) >= MIN_GPS_MOTION_SAMPLES: + qualified_gps.update(seq) + + # All motion indices, sorted + motion_indices = sorted(set(obd_motion) | qualified_gps) + if not motion_indices: + return 0 + + # Group motion indices into trip boundaries by inactivity gap + groups = [] + for idx in motion_indices: + if not groups or (rows[idx][0] - rows[groups[-1][-1]][0]).total_seconds() > INACTIVITY_SECONDS: + groups.append([idx]) + else: + groups[-1].append(idx) + + # Build trip records from each group trips = [] - for seg in segments: - if len(seg) < 2: + for group in groups: + start_idx = max(0, group[0] - 1) + end_idx = group[-1] + seg_rows = rows[start_idx:end_idx + 1] + + distance = 0 + speeds = [] + for i in range(1, len(seg_rows)): + d = haversine_km(seg_rows[i - 1][1], seg_rows[i - 1][2], + seg_rows[i][1], seg_rows[i][2]) + distance += d + s = seg_rows[i][3] + if s is not None and s > 0: + speeds.append(s) + + duration_s = int((seg_rows[-1][0] - seg_rows[0][0]).total_seconds()) + if duration_s < 30 or distance < 0.05: continue - start_time = seg[0][0] - end_time = seg[-1][0] - duration_s = int((end_time - start_time).total_seconds()) - if duration_s < 30: - continue - dist = sum( - haversine(seg[i][1], seg[i][2], seg[i+1][1], seg[i+1][2]) - for i in range(len(seg) - 1) - ) - if dist < 0.05: # skip <50m blips - continue - speeds = [r[3] for r in seg if r[3] is not None] + trips.append({ - "start_time": start_time, - "end_time": end_time, - "distance_km": round(dist, 3), + "start_time": seg_rows[0][0], + "end_time": seg_rows[-1][0], + "distance_km": round(distance, 3), "duration_s": duration_s, "avg_speed_kmh": round(sum(speeds) / len(speeds), 1) if speeds else None, "max_speed_kmh": round(max(speeds), 1) if speeds else None, - "start_lat": seg[0][1], - "start_lon": seg[0][2], - "end_lat": seg[-1][1], - "end_lon": seg[-1][2], - "point_count": len(seg), + "start_lat": seg_rows[0][1], + "start_lon": seg_rows[0][2], + "end_lat": seg_rows[-1][1], + "end_lon": seg_rows[-1][2], + "point_count": len(seg_rows), }) with get_cursor(commit=True) as cur: