Add trip history and route replay
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
INACTIVITY_SECONDS = 5 * 60
|
||||
GPS_MOTION_METERS = 8
|
||||
MIN_GPS_MOTION_SAMPLES = 3
|
||||
MAX_GPS_SAMPLE_GAP_SECONDS = 30
|
||||
MAX_PLAUSIBLE_SPEED_KMH = 220
|
||||
EARTH_RADIUS_KM = 6371.0088
|
||||
|
||||
|
||||
def _number(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(str(value).replace("\x00", "").strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _timestamp(value):
|
||||
try:
|
||||
return datetime.fromisoformat(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def haversine_km(left, right):
|
||||
lat1, lon1 = map(math.radians, left)
|
||||
lat2, lon2 = map(math.radians, right)
|
||||
latitude_delta = lat2 - lat1
|
||||
longitude_delta = lon2 - lon1
|
||||
value = (
|
||||
math.sin(latitude_delta / 2) ** 2
|
||||
+ math.cos(lat1) * math.cos(lat2)
|
||||
* math.sin(longitude_delta / 2) ** 2
|
||||
)
|
||||
return EARTH_RADIUS_KM * 2 * math.atan2(math.sqrt(value), math.sqrt(1 - value))
|
||||
|
||||
|
||||
def normalize_tracking_rows(rows):
|
||||
normalized = []
|
||||
for row in rows:
|
||||
timestamp = _timestamp(row[1])
|
||||
latitude = _number(row[2])
|
||||
longitude = _number(row[3])
|
||||
speed = _number(row[4]) or 0
|
||||
if timestamp is None or latitude is None or longitude is None:
|
||||
continue
|
||||
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
||||
continue
|
||||
normalized.append({
|
||||
"id": int(row[0]),
|
||||
"datetime": timestamp,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"speed": max(0, speed),
|
||||
})
|
||||
return normalized
|
||||
|
||||
|
||||
def _segment(row, previous):
|
||||
seconds = (row["datetime"] - previous["datetime"]).total_seconds()
|
||||
if seconds <= 0:
|
||||
return 0, 0
|
||||
distance_km = haversine_km(
|
||||
(previous["latitude"], previous["longitude"]),
|
||||
(row["latitude"], row["longitude"]),
|
||||
)
|
||||
speed_kmh = distance_km / seconds * 3600
|
||||
if speed_kmh > MAX_PLAUSIBLE_SPEED_KMH:
|
||||
return 0, speed_kmh
|
||||
return distance_km, speed_kmh
|
||||
|
||||
|
||||
def build_trip_summaries(raw_rows):
|
||||
rows = normalize_tracking_rows(raw_rows)
|
||||
if len(rows) < 2:
|
||||
return []
|
||||
|
||||
obd_motion = []
|
||||
gps_motion = []
|
||||
for index in range(1, len(rows)):
|
||||
seconds = (
|
||||
rows[index]["datetime"] - rows[index - 1]["datetime"]
|
||||
).total_seconds()
|
||||
distance_km, _ = _segment(rows[index], rows[index - 1])
|
||||
has_obd_motion = rows[index]["speed"] > 1
|
||||
has_gps_motion = (
|
||||
0 < seconds <= MAX_GPS_SAMPLE_GAP_SECONDS
|
||||
and distance_km * 1000 >= GPS_MOTION_METERS
|
||||
)
|
||||
if has_obd_motion:
|
||||
obd_motion.append({
|
||||
"index": index,
|
||||
"obd": True,
|
||||
"distance_km": distance_km,
|
||||
})
|
||||
if has_gps_motion:
|
||||
gps_motion.append({
|
||||
"index": index,
|
||||
"obd": False,
|
||||
"distance_km": distance_km,
|
||||
})
|
||||
|
||||
qualified_gps_motion = []
|
||||
gps_sequences = []
|
||||
for event in gps_motion:
|
||||
if (
|
||||
not gps_sequences
|
||||
or (
|
||||
rows[event["index"]]["datetime"]
|
||||
- rows[gps_sequences[-1][-1]["index"]]["datetime"]
|
||||
).total_seconds() > MAX_GPS_SAMPLE_GAP_SECONDS
|
||||
):
|
||||
gps_sequences.append([event])
|
||||
else:
|
||||
gps_sequences[-1].append(event)
|
||||
for sequence in gps_sequences:
|
||||
if len(sequence) >= MIN_GPS_MOTION_SAMPLES:
|
||||
qualified_gps_motion.extend(sequence)
|
||||
|
||||
motion_by_index = {}
|
||||
for event in obd_motion + qualified_gps_motion:
|
||||
existing = motion_by_index.get(event["index"])
|
||||
if existing is None:
|
||||
motion_by_index[event["index"]] = event.copy()
|
||||
else:
|
||||
existing["obd"] = existing["obd"] or event["obd"]
|
||||
existing["distance_km"] = max(
|
||||
existing["distance_km"], event["distance_km"]
|
||||
)
|
||||
motion = [
|
||||
motion_by_index[index] for index in sorted(motion_by_index)
|
||||
]
|
||||
|
||||
groups = []
|
||||
for event in motion:
|
||||
event_gap = (
|
||||
(
|
||||
rows[event["index"]]["datetime"]
|
||||
- rows[groups[-1][-1]["index"]]["datetime"]
|
||||
).total_seconds()
|
||||
if groups
|
||||
else None
|
||||
)
|
||||
if (
|
||||
not groups
|
||||
or event_gap <= 0
|
||||
or event_gap > INACTIVITY_SECONDS
|
||||
):
|
||||
groups.append([event])
|
||||
else:
|
||||
groups[-1].append(event)
|
||||
|
||||
trips = []
|
||||
for events in groups:
|
||||
if not any(event["obd"] for event in events) and len(events) < MIN_GPS_MOTION_SAMPLES:
|
||||
continue
|
||||
|
||||
start_index = max(0, events[0]["index"] - 1)
|
||||
end_index = events[-1]["index"]
|
||||
trip_rows = rows[start_index:end_index + 1]
|
||||
distance_km = 0
|
||||
segment_speeds = []
|
||||
obd_speeds = []
|
||||
has_obd_motion = any(event["obd"] for event in events)
|
||||
for previous, row in zip(trip_rows, trip_rows[1:]):
|
||||
segment_km, segment_speed = _segment(row, previous)
|
||||
minimum_distance_meters = 1 if has_obd_motion else GPS_MOTION_METERS
|
||||
if segment_km * 1000 >= minimum_distance_meters:
|
||||
distance_km += segment_km
|
||||
segment_speeds.append(segment_speed)
|
||||
if row["speed"] > 0:
|
||||
obd_speeds.append(row["speed"])
|
||||
|
||||
start = trip_rows[0]
|
||||
end = trip_rows[-1]
|
||||
duration_seconds = max(
|
||||
0, int((end["datetime"] - start["datetime"]).total_seconds())
|
||||
)
|
||||
if not any(event["obd"] for event in events) and duration_seconds < 10:
|
||||
continue
|
||||
|
||||
trips.append({
|
||||
"id": start["id"],
|
||||
"start_tracking_id": start["id"],
|
||||
"end_tracking_id": end["id"],
|
||||
"start_datetime": start["datetime"].isoformat(sep=" "),
|
||||
"end_datetime": end["datetime"].isoformat(sep=" "),
|
||||
"start_gpsx": start["latitude"],
|
||||
"start_gpsy": start["longitude"],
|
||||
"end_gpsx": end["latitude"],
|
||||
"end_gpsy": end["longitude"],
|
||||
"distance_km": round(distance_km, 3),
|
||||
"duration_seconds": duration_seconds,
|
||||
"point_count": len(trip_rows),
|
||||
"average_speed_kmh": round(
|
||||
distance_km / duration_seconds * 3600, 1
|
||||
) if duration_seconds else 0,
|
||||
"max_speed_kmh": round(
|
||||
max(obd_speeds or segment_speeds or [0]), 1
|
||||
),
|
||||
})
|
||||
return trips
|
||||
|
||||
|
||||
def route_points(raw_rows, max_points=2000):
|
||||
rows = normalize_tracking_rows(raw_rows)
|
||||
filtered_rows = []
|
||||
for row in rows:
|
||||
if filtered_rows:
|
||||
_, segment_speed = _segment(row, filtered_rows[-1])
|
||||
if segment_speed > MAX_PLAUSIBLE_SPEED_KMH:
|
||||
continue
|
||||
filtered_rows.append(row)
|
||||
rows = filtered_rows
|
||||
for index, row in enumerate(rows):
|
||||
row["speed_source"] = "obd" if row["speed"] > 0 else "gps"
|
||||
if row["speed"] <= 0 and index:
|
||||
distance_km, segment_speed = _segment(row, rows[index - 1])
|
||||
row["speed"] = (
|
||||
segment_speed
|
||||
if distance_km * 1000 >= GPS_MOTION_METERS
|
||||
else 0
|
||||
)
|
||||
if len(rows) <= max_points:
|
||||
selected = rows
|
||||
else:
|
||||
indexes = {
|
||||
round(index * (len(rows) - 1) / (max_points - 1))
|
||||
for index in range(max_points)
|
||||
}
|
||||
selected = [row for index, row in enumerate(rows) if index in indexes]
|
||||
return [{
|
||||
"id": row["id"],
|
||||
"datetime": row["datetime"].isoformat(sep=" "),
|
||||
"latitude": row["latitude"],
|
||||
"longitude": row["longitude"],
|
||||
"speed": round(row["speed"], 1),
|
||||
"speed_source": row["speed_source"],
|
||||
} for row in selected]
|
||||
Reference in New Issue
Block a user