fix: detect flights from activity segment coords; fix category for UNKNOWN/ALIASED_LOCATION
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
tests / Detect unreleased dependencies (push) Has been cancelled
tests / test with OCB (push) Has been cancelled
tests / test with Odoo (push) Has been cancelled

This commit is contained in:
2026-03-14 04:03:23 +00:00
parent 334a1a0f88
commit 1b796b6ba4

View File

@@ -21,7 +21,8 @@ SEMANTIC_TYPE_CATEGORY = {
'WORK': 'Work', 'WORK': 'Work',
'INFERRED_WORK': 'Work', 'INFERRED_WORK': 'Work',
'SEARCHED_ADDRESS': 'Searched Address', 'SEARCHED_ADDRESS': 'Searched Address',
'UNKNOWN': '', 'ALIASED_LOCATION': 'Saved Place',
'UNKNOWN': 'Unknown',
} }
@@ -45,8 +46,6 @@ def _activity_type_to_travel_mode(activity_type):
return None return None
if t == 'IN_RAIL_VEHICLE': if t == 'IN_RAIL_VEHICLE':
return 'transit' return 'transit'
if t == 'IN_PASSENGER_VEHICLE' or t == 'FLYING':
return 'flying'
if t in VEHICLE_ACTIVITIES: if t in VEHICLE_ACTIVITIES:
return 'driving' return 'driving'
if t in WALKING_ACTIVITIES: if t in WALKING_ACTIVITIES:
@@ -57,7 +56,7 @@ def _activity_type_to_travel_mode(activity_type):
def _speed_to_travel_mode(max_speed_mph): def _speed_to_travel_mode(max_speed_mph):
"""Infer travel mode from maximum speed observed along a path.""" """Infer travel mode from maximum speed observed."""
if max_speed_mph > 150: if max_speed_mph > 150:
return 'flying' return 'flying'
if max_speed_mph > 15: if max_speed_mph > 15:
@@ -115,6 +114,29 @@ def _max_speed_from_path(timeline_path):
return max_speed return max_speed
def _speed_from_activity_segment(activity, seg_start_ts, seg_end_ts):
"""Calculate implied speed from an activity segment's start/end coords and timestamps.
Flights appear as UNKNOWN_ACTIVITY_TYPE with valid start/end latLng but no
timelinePath GPS trail, so we derive speed directly from the segment geometry.
"""
start_latlng = activity.get('start', {}).get('latLng', '')
end_latlng = activity.get('end', {}).get('latLng', '')
if not start_latlng or not end_latlng:
return 0.0
slat, slng = _parse_latlng(start_latlng)
elat, elng = _parse_latlng(end_latlng)
if slat is None or elat is None:
return 0.0
if not seg_start_ts or not seg_end_ts:
return 0.0
dt_hours = (seg_end_ts - seg_start_ts).total_seconds() / 3600.0
if dt_hours <= 0:
return 0.0
dist_miles = _haversine_miles(slat, slng, elat, elng)
return dist_miles / dt_hours
class WtImportTimelineWizard(models.TransientModel): class WtImportTimelineWizard(models.TransientModel):
_name = 'wt.import.timeline.wizard' _name = 'wt.import.timeline.wizard'
_description = 'Import Google Timeline' _description = 'Import Google Timeline'
@@ -250,34 +272,45 @@ class WtImportTimelineWizard(models.TransientModel):
def _parse_semantic_timeline(self, data): def _parse_semantic_timeline(self, data):
"""Parse Timeline.json (semanticSegments format). """Parse Timeline.json (semanticSegments format).
Travel mode is determined in priority order: Travel mode priority:
1. activity segment topCandidate.type (if not UNKNOWN) 1. Explicit activity type (if not UNKNOWN)
2. max speed calculated from preceding timelinePath points 2. Speed inferred from activity segment start/end coords + timestamps
3. 'unknown' as fallback (catches flights that have no timelinePath GPS trail)
3. Max speed calculated from preceding timelinePath points
4. 'unknown' as fallback
""" """
stops = [] stops = []
segments = data.get('semanticSegments', []) segments = data.get('semanticSegments', [])
# We scan segments in order, accumulating travel signals between visits
pending_mode = 'unknown' pending_mode = 'unknown'
pending_path_points = [] # all timelinePath points seen since last visit pending_path_points = []
for seg in segments: for seg in segments:
# --- timelinePath: collect GPS path points for speed inference --- # --- timelinePath: collect GPS points for speed inference ---
if 'timelinePath' in seg: if 'timelinePath' in seg:
pending_path_points.extend(seg['timelinePath']) pending_path_points.extend(seg['timelinePath'])
continue continue
# --- activity segment: try to get explicit travel mode --- # --- activity segment ---
if 'activity' in seg: if 'activity' in seg:
activity = seg['activity'] activity = seg['activity']
top = activity.get('topCandidate', {}) top = activity.get('topCandidate', {})
mode = _activity_type_to_travel_mode(top.get('type', '')) mode = _activity_type_to_travel_mode(top.get('type', ''))
if mode: if mode:
# Explicit type wins (driving, walking, transit, cycling)
pending_mode = mode pending_mode = mode
else:
# UNKNOWN type — infer from segment distance / elapsed time
seg_start = _parse_ts(seg.get('startTime'))
seg_end = _parse_ts(seg.get('endTime'))
speed = _speed_from_activity_segment(activity, seg_start, seg_end)
if speed > 0:
inferred = _speed_to_travel_mode(speed)
if inferred != 'unknown':
pending_mode = inferred
continue continue
# --- visit segment: the stop we care about --- # --- visit segment: the stop we record ---
if 'visit' in seg: if 'visit' in seg:
start_ts = _parse_ts(seg.get('startTime')) start_ts = _parse_ts(seg.get('startTime'))
end_ts = _parse_ts(seg.get('endTime')) end_ts = _parse_ts(seg.get('endTime'))
@@ -287,16 +320,16 @@ class WtImportTimelineWizard(models.TransientModel):
continue continue
candidate = seg['visit'].get('topCandidate', {}) candidate = seg['visit'].get('topCandidate', {})
semantic_type = candidate.get('semanticType', '') semantic_type = candidate.get('semanticType', 'UNKNOWN')
latlng_str = candidate.get('placeLocation', {}).get('latLng', '') latlng_str = candidate.get('placeLocation', {}).get('latLng', '')
lat, lng = _parse_latlng(latlng_str) if latlng_str else (None, None) lat, lng = _parse_latlng(latlng_str) if latlng_str else (None, None)
category = SEMANTIC_TYPE_CATEGORY.get(semantic_type, '') category = SEMANTIC_TYPE_CATEGORY.get(
if not category and semantic_type: semantic_type,
category = semantic_type.replace('_', ' ').title() semantic_type.replace('_', ' ').title() if semantic_type else 'Unknown'
)
# Determine travel mode: explicit activity type wins, # Determine travel mode
# otherwise infer from max speed along the preceding path
travel_mode = pending_mode travel_mode = pending_mode
if travel_mode == 'unknown' and pending_path_points: if travel_mode == 'unknown' and pending_path_points:
max_speed = _max_speed_from_path(pending_path_points) max_speed = _max_speed_from_path(pending_path_points)
@@ -312,12 +345,11 @@ class WtImportTimelineWizard(models.TransientModel):
'travel_mode': travel_mode, 'travel_mode': travel_mode,
}) })
# Reset for next inter-visit gap
pending_mode = 'unknown' pending_mode = 'unknown'
pending_path_points = [] pending_path_points = []
continue continue
# timelineMemory or other unknown segment types — ignore # timelineMemory or other segment types — ignore
pass pass
return stops return stops