diff --git a/work_trace/wizards/wt_import_timeline_wizard.py b/work_trace/wizards/wt_import_timeline_wizard.py index feddc63a..f9c2a722 100644 --- a/work_trace/wizards/wt_import_timeline_wizard.py +++ b/work_trace/wizards/wt_import_timeline_wizard.py @@ -21,7 +21,8 @@ SEMANTIC_TYPE_CATEGORY = { 'WORK': 'Work', 'INFERRED_WORK': 'Work', '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 if t == 'IN_RAIL_VEHICLE': return 'transit' - if t == 'IN_PASSENGER_VEHICLE' or t == 'FLYING': - return 'flying' if t in VEHICLE_ACTIVITIES: return 'driving' 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): - """Infer travel mode from maximum speed observed along a path.""" + """Infer travel mode from maximum speed observed.""" if max_speed_mph > 150: return 'flying' if max_speed_mph > 15: @@ -115,6 +114,29 @@ def _max_speed_from_path(timeline_path): 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): _name = 'wt.import.timeline.wizard' _description = 'Import Google Timeline' @@ -250,34 +272,45 @@ class WtImportTimelineWizard(models.TransientModel): def _parse_semantic_timeline(self, data): """Parse Timeline.json (semanticSegments format). - Travel mode is determined in priority order: - 1. activity segment topCandidate.type (if not UNKNOWN) - 2. max speed calculated from preceding timelinePath points - 3. 'unknown' as fallback + Travel mode priority: + 1. Explicit activity type (if not UNKNOWN) + 2. Speed inferred from activity segment start/end coords + timestamps + (catches flights that have no timelinePath GPS trail) + 3. Max speed calculated from preceding timelinePath points + 4. 'unknown' as fallback """ stops = [] segments = data.get('semanticSegments', []) - # We scan segments in order, accumulating travel signals between visits pending_mode = 'unknown' - pending_path_points = [] # all timelinePath points seen since last visit + pending_path_points = [] for seg in segments: - # --- timelinePath: collect GPS path points for speed inference --- + # --- timelinePath: collect GPS points for speed inference --- if 'timelinePath' in seg: pending_path_points.extend(seg['timelinePath']) continue - # --- activity segment: try to get explicit travel mode --- + # --- activity segment --- if 'activity' in seg: activity = seg['activity'] top = activity.get('topCandidate', {}) mode = _activity_type_to_travel_mode(top.get('type', '')) if mode: + # Explicit type wins (driving, walking, transit, cycling) 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 - # --- visit segment: the stop we care about --- + # --- visit segment: the stop we record --- if 'visit' in seg: start_ts = _parse_ts(seg.get('startTime')) end_ts = _parse_ts(seg.get('endTime')) @@ -287,16 +320,16 @@ class WtImportTimelineWizard(models.TransientModel): continue candidate = seg['visit'].get('topCandidate', {}) - semantic_type = candidate.get('semanticType', '') + semantic_type = candidate.get('semanticType', 'UNKNOWN') latlng_str = candidate.get('placeLocation', {}).get('latLng', '') lat, lng = _parse_latlng(latlng_str) if latlng_str else (None, None) - category = SEMANTIC_TYPE_CATEGORY.get(semantic_type, '') - if not category and semantic_type: - category = semantic_type.replace('_', ' ').title() + category = SEMANTIC_TYPE_CATEGORY.get( + semantic_type, + semantic_type.replace('_', ' ').title() if semantic_type else 'Unknown' + ) - # Determine travel mode: explicit activity type wins, - # otherwise infer from max speed along the preceding path + # Determine travel mode travel_mode = pending_mode if travel_mode == 'unknown' and pending_path_points: max_speed = _max_speed_from_path(pending_path_points) @@ -312,12 +345,11 @@ class WtImportTimelineWizard(models.TransientModel): 'travel_mode': travel_mode, }) - # Reset for next inter-visit gap pending_mode = 'unknown' pending_path_points = [] continue - # timelineMemory or other unknown segment types — ignore + # timelineMemory or other segment types — ignore pass return stops