fix(phase26): server trip detection mirrors Pi motion-aware logic (BUG-04)

This commit is contained in:
Mathieu Moran
2026-08-11 02:27:17 -04:00
parent 447d73bbe5
commit 96fb8971eb
+88 -38
View File
@@ -280,7 +280,19 @@ _TRIP_GAP_S = 300 # 5 minutes
def compute_trips_for_device(device_id): 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: with get_cursor() as cur:
cur.execute( cur.execute(
""" """
@@ -293,59 +305,97 @@ def compute_trips_for_device(device_id):
) )
rows = cur.fetchall() rows = cur.fetchall()
if not rows: if len(rows) < 2:
return 0 return 0
import math import math
def haversine(lat1, lon1, lat2, lon2): def haversine_km(lat1, lon1, lat2, lon2):
R = 6371.0 R = 6371.0
dlat = math.radians(lat2 - lat1) dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1) 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)) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
# Split into segments by gap # Detect motion events per step
segments = [] obd_motion = [] # indices where OBD speed > 1 km/h
current = [rows[0]] gps_motion = [] # indices where GPS displacement >= 8 m
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)
# 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 = [] trips = []
for seg in segments: for group in groups:
if len(seg) < 2: 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 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({ trips.append({
"start_time": start_time, "start_time": seg_rows[0][0],
"end_time": end_time, "end_time": seg_rows[-1][0],
"distance_km": round(dist, 3), "distance_km": round(distance, 3),
"duration_s": duration_s, "duration_s": duration_s,
"avg_speed_kmh": round(sum(speeds) / len(speeds), 1) if speeds else None, "avg_speed_kmh": round(sum(speeds) / len(speeds), 1) if speeds else None,
"max_speed_kmh": round(max(speeds), 1) if speeds else None, "max_speed_kmh": round(max(speeds), 1) if speeds else None,
"start_lat": seg[0][1], "start_lat": seg_rows[0][1],
"start_lon": seg[0][2], "start_lon": seg_rows[0][2],
"end_lat": seg[-1][1], "end_lat": seg_rows[-1][1],
"end_lon": seg[-1][2], "end_lon": seg_rows[-1][2],
"point_count": len(seg), "point_count": len(seg_rows),
}) })
with get_cursor(commit=True) as cur: with get_cursor(commit=True) as cur: