fix: infer travel_mode from timelinePath speed when activity type is UNKNOWN
This commit is contained in:
@@ -38,18 +38,32 @@ def _distance_meters(lat1, lon1, lat2, lon2):
|
|||||||
|
|
||||||
|
|
||||||
def _activity_type_to_travel_mode(activity_type):
|
def _activity_type_to_travel_mode(activity_type):
|
||||||
"""Map Google activity type string to Odoo travel_mode selection value."""
|
|
||||||
if not activity_type:
|
if not activity_type:
|
||||||
return 'unknown'
|
return None
|
||||||
t = activity_type.upper()
|
t = activity_type.upper()
|
||||||
if t in VEHICLE_ACTIVITIES:
|
if 'UNKNOWN' in t:
|
||||||
return 'driving'
|
return None
|
||||||
if t == 'IN_RAIL_VEHICLE':
|
if t == 'IN_RAIL_VEHICLE':
|
||||||
return 'transit'
|
return 'transit'
|
||||||
|
if t in VEHICLE_ACTIVITIES:
|
||||||
|
return 'driving'
|
||||||
if t in WALKING_ACTIVITIES:
|
if t in WALKING_ACTIVITIES:
|
||||||
return 'walking'
|
return 'walking'
|
||||||
if t in CYCLING_ACTIVITIES:
|
if t in CYCLING_ACTIVITIES:
|
||||||
return 'cycling'
|
return 'cycling'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _speed_to_travel_mode(max_speed_mph):
|
||||||
|
"""Infer travel mode from maximum speed observed along a path."""
|
||||||
|
if max_speed_mph > 150:
|
||||||
|
return 'transit' # flight
|
||||||
|
if max_speed_mph > 15:
|
||||||
|
return 'driving'
|
||||||
|
if max_speed_mph > 3:
|
||||||
|
return 'cycling'
|
||||||
|
if max_speed_mph > 0:
|
||||||
|
return 'walking'
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +83,36 @@ def _parse_ts(ts_str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _max_speed_from_path(timeline_path):
|
||||||
|
"""Calculate the maximum speed (mph) from a timelinePath segment's points."""
|
||||||
|
points = []
|
||||||
|
for pt in timeline_path:
|
||||||
|
latlng = pt.get('point', '')
|
||||||
|
ts_str = pt.get('time', '')
|
||||||
|
lat, lng = _parse_latlng(latlng)
|
||||||
|
ts = _parse_ts(ts_str)
|
||||||
|
if lat is not None and ts is not None:
|
||||||
|
points.append((ts, lat, lng))
|
||||||
|
|
||||||
|
if len(points) < 2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
points.sort(key=lambda x: x[0])
|
||||||
|
max_speed = 0.0
|
||||||
|
for i in range(1, len(points)):
|
||||||
|
t0, lat0, lng0 = points[i - 1]
|
||||||
|
t1, lat1, lng1 = points[i]
|
||||||
|
dt_hours = (t1 - t0).total_seconds() / 3600.0
|
||||||
|
if dt_hours <= 0:
|
||||||
|
continue
|
||||||
|
dist_miles = _haversine_miles(lat0, lng0, lat1, lng1)
|
||||||
|
speed = dist_miles / dt_hours
|
||||||
|
if speed > max_speed:
|
||||||
|
max_speed = speed
|
||||||
|
|
||||||
|
return max_speed
|
||||||
|
|
||||||
|
|
||||||
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'
|
||||||
@@ -102,7 +146,6 @@ class WtImportTimelineWizard(models.TransientModel):
|
|||||||
def action_import(self):
|
def action_import(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
# Load data from file upload or server path
|
|
||||||
if self.import_mode == 'server_path':
|
if self.import_mode == 'server_path':
|
||||||
if not self.server_file_path:
|
if not self.server_file_path:
|
||||||
raise UserError(_('Please enter the server file path.'))
|
raise UserError(_('Please enter the server file path.'))
|
||||||
@@ -123,7 +166,6 @@ class WtImportTimelineWizard(models.TransientModel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise UserError(_('Invalid JSON file: %s') % str(e))
|
raise UserError(_('Invalid JSON file: %s') % str(e))
|
||||||
|
|
||||||
# Auto-detect format
|
|
||||||
if 'semanticSegments' in data:
|
if 'semanticSegments' in data:
|
||||||
stops = self._parse_semantic_timeline(data)
|
stops = self._parse_semantic_timeline(data)
|
||||||
elif 'timelineEdits' in data:
|
elif 'timelineEdits' in data:
|
||||||
@@ -134,7 +176,6 @@ class WtImportTimelineWizard(models.TransientModel):
|
|||||||
if not stops:
|
if not stops:
|
||||||
raise UserError(_('No location stops found in the file.'))
|
raise UserError(_('No location stops found in the file.'))
|
||||||
|
|
||||||
# Filter by minimum stop duration
|
|
||||||
min_secs = self.min_stop_minutes * 60
|
min_secs = self.min_stop_minutes * 60
|
||||||
stops = [s for s in stops
|
stops = [s for s in stops
|
||||||
if s.get('arrived_at') and s.get('departed_at')
|
if s.get('arrived_at') and s.get('departed_at')
|
||||||
@@ -145,7 +186,6 @@ class WtImportTimelineWizard(models.TransientModel):
|
|||||||
|
|
||||||
stops.sort(key=lambda s: s['arrived_at'])
|
stops.sort(key=lambda s: s['arrived_at'])
|
||||||
|
|
||||||
# Compute distances and travel times
|
|
||||||
for i, stop in enumerate(stops):
|
for i, stop in enumerate(stops):
|
||||||
if i > 0:
|
if i > 0:
|
||||||
prev = stops[i - 1]
|
prev = stops[i - 1]
|
||||||
@@ -159,7 +199,6 @@ class WtImportTimelineWizard(models.TransientModel):
|
|||||||
stop['distance_from_previous'] = 0.0
|
stop['distance_from_previous'] = 0.0
|
||||||
stop['travel_time_from_previous'] = 0.0
|
stop['travel_time_from_previous'] = 0.0
|
||||||
|
|
||||||
# Skip existing records (deduplicate by arrived_at)
|
|
||||||
LocationLog = self.env['wt.location.log']
|
LocationLog = self.env['wt.location.log']
|
||||||
existing = set(
|
existing = set(
|
||||||
r.arrived_at.strftime('%Y-%m-%d %H:%M:%S')
|
r.arrived_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
@@ -207,59 +246,77 @@ class WtImportTimelineWizard(models.TransientModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _parse_semantic_timeline(self, data):
|
def _parse_semantic_timeline(self, data):
|
||||||
"""Parse Timeline.json (semanticSegments format from Android export).
|
"""Parse Timeline.json (semanticSegments format).
|
||||||
|
|
||||||
Iterates segments in order. Activity segments (travel between stops)
|
Travel mode is determined in priority order:
|
||||||
are captured to provide travel_mode for the following visit segment.
|
1. activity segment topCandidate.type (if not UNKNOWN)
|
||||||
|
2. max speed calculated from preceding timelinePath points
|
||||||
|
3. 'unknown' as fallback
|
||||||
"""
|
"""
|
||||||
stops = []
|
stops = []
|
||||||
segments = data.get('semanticSegments', [])
|
segments = data.get('semanticSegments', [])
|
||||||
pending_travel_mode = 'unknown'
|
|
||||||
|
# We scan segments in order, accumulating travel signals between visits
|
||||||
|
pending_mode = 'unknown'
|
||||||
|
pending_path_points = [] # all timelinePath points seen since last visit
|
||||||
|
|
||||||
for seg in segments:
|
for seg in segments:
|
||||||
# --- Activity / travel segment ---
|
# --- timelinePath: collect GPS path points for speed inference ---
|
||||||
activity = seg.get('activity')
|
if 'timelinePath' in seg:
|
||||||
if activity:
|
pending_path_points.extend(seg['timelinePath'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- activity segment: try to get explicit travel mode ---
|
||||||
|
if 'activity' in seg:
|
||||||
|
activity = seg['activity']
|
||||||
top = activity.get('topCandidate', {})
|
top = activity.get('topCandidate', {})
|
||||||
activity_type = top.get('type', '')
|
mode = _activity_type_to_travel_mode(top.get('type', ''))
|
||||||
mode = _activity_type_to_travel_mode(activity_type)
|
if mode:
|
||||||
if mode != 'unknown':
|
pending_mode = mode
|
||||||
pending_travel_mode = mode
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- Visit / stop segment ---
|
# --- visit segment: the stop we care about ---
|
||||||
visit = seg.get('visit')
|
if 'visit' in seg:
|
||||||
if not visit:
|
start_ts = _parse_ts(seg.get('startTime'))
|
||||||
# timelinePath or unknown — ignore but keep pending travel mode
|
end_ts = _parse_ts(seg.get('endTime'))
|
||||||
|
if not start_ts or not end_ts:
|
||||||
|
pending_mode = 'unknown'
|
||||||
|
pending_path_points = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = seg['visit'].get('topCandidate', {})
|
||||||
|
semantic_type = candidate.get('semanticType', '')
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Determine travel mode: explicit activity type wins,
|
||||||
|
# otherwise infer from max speed along the preceding path
|
||||||
|
travel_mode = pending_mode
|
||||||
|
if travel_mode == 'unknown' and pending_path_points:
|
||||||
|
max_speed = _max_speed_from_path(pending_path_points)
|
||||||
|
travel_mode = _speed_to_travel_mode(max_speed)
|
||||||
|
|
||||||
|
stops.append({
|
||||||
|
'arrived_at': start_ts,
|
||||||
|
'departed_at': end_ts,
|
||||||
|
'lat': lat,
|
||||||
|
'lng': lng,
|
||||||
|
'place_name': '',
|
||||||
|
'category': category,
|
||||||
|
'travel_mode': travel_mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Reset for next inter-visit gap
|
||||||
|
pending_mode = 'unknown'
|
||||||
|
pending_path_points = []
|
||||||
continue
|
continue
|
||||||
|
|
||||||
start_ts = _parse_ts(seg.get('startTime'))
|
# timelineMemory or other unknown segment types — ignore
|
||||||
end_ts = _parse_ts(seg.get('endTime'))
|
pass
|
||||||
if not start_ts or not end_ts:
|
|
||||||
pending_travel_mode = 'unknown'
|
|
||||||
continue
|
|
||||||
|
|
||||||
candidate = visit.get('topCandidate', {})
|
|
||||||
semantic_type = candidate.get('semanticType', '')
|
|
||||||
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()
|
|
||||||
|
|
||||||
stops.append({
|
|
||||||
'arrived_at': start_ts,
|
|
||||||
'departed_at': end_ts,
|
|
||||||
'lat': lat,
|
|
||||||
'lng': lng,
|
|
||||||
'place_name': '',
|
|
||||||
'category': category,
|
|
||||||
'travel_mode': pending_travel_mode,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Reset after consuming for the next visit
|
|
||||||
pending_travel_mode = 'unknown'
|
|
||||||
|
|
||||||
return stops
|
return stops
|
||||||
|
|
||||||
@@ -298,7 +355,8 @@ class WtImportTimelineWizard(models.TransientModel):
|
|||||||
if not window:
|
if not window:
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
counts = Counter(a['type'] for a in window)
|
counts = Counter(a['type'] for a in window)
|
||||||
return _activity_type_to_travel_mode(counts.most_common(1)[0][0])
|
top_type = counts.most_common(1)[0][0]
|
||||||
|
return _activity_type_to_travel_mode(top_type) or 'unknown'
|
||||||
|
|
||||||
stops = []
|
stops = []
|
||||||
current_cluster = [positions[0]]
|
current_cluster = [positions[0]]
|
||||||
|
|||||||
Reference in New Issue
Block a user