import xml.etree.ElementTree as ET from datetime import datetime import math import logging logger = logging.getLogger(__name__) def parse_gpx_file(file_path): """ Parse GPX file to extract activity metrics. Returns: Dictionary of activity metrics or None if parsing fails """ try: tree = ET.parse(file_path) root = tree.getroot() # GPX namespace ns = {'gpx': 'http://www.topografix.com/GPX/1/1'} # Extract metadata metadata = root.find('gpx:metadata', ns) if metadata is not None: time_elem = metadata.find('gpx:time', ns) if time_elem is not None: start_time = datetime.fromisoformat(time_elem.text.replace('Z', '+00:00')) else: # Fallback to first track point time trkpt = root.find('.//gpx:trkpt', ns) if trkpt is not None: time_elem = trkpt.find('gpx:time', ns) if time_elem is not None: start_time = datetime.fromisoformat(time_elem.text.replace('Z', '+00:00')) else: logger.error(f"No track points found in GPX file: {file_path}") return None # Get all track points track_points = root.findall('.//gpx:trkpt', ns) if not track_points: logger.warning(f"No track points found in GPX file: {file_path}") return None # Activity metrics total_distance = 0.0 start_elevation = None min_elevation = float('inf') max_elevation = float('-inf') elevations = [] heart_rates = [] cadences = [] prev_point = None for point in track_points: # Parse coordinates lat = float(point.get('lat')) lon = float(point.get('lon')) # Parse elevation ele_elem = point.find('gpx:ele', ns) ele = float(ele_elem.text) if ele_elem is not None else None if ele is not None: elevations.append(ele) if start_elevation is None: start_elevation = ele min_elevation = min(min_elevation, ele) max_elevation = max(max_elevation, ele) # Parse time time_elem = point.find('gpx:time', ns) time = datetime.fromisoformat(time_elem.text.replace('Z', '+00:00')) if time_elem else None # Parse extensions (heart rate, cadence, etc.) extensions = point.find('gpx:extensions', ns) if extensions is not None: # Garmin TrackPointExtension tpe = extensions.find('gpx:TrackPointExtension', ns) if tpe is not None: hr_elem = tpe.find('gpx:hr', ns) if hr_elem is not None: heart_rates.append(int(hr_elem.text)) cad_elem = tpe.find('gpx:cad', ns) if cad_elem is not None: cadences.append(int(cad_elem.text)) # Calculate distance from previous point if prev_point: prev_lat, prev_lon = prev_point total_distance += haversine(prev_lat, prev_lon, lat, lon) prev_point = (lat, lon) # Calculate duration if 'start_time' in locals() and time is not None: duration = (time - start_time).total_seconds() else: duration = None # Calculate elevation gain/loss elevation_gain = 0 elevation_loss = 0 if elevations: prev_ele = elevations[0] for ele in elevations[1:]: if ele > prev_ele: elevation_gain += ele - prev_ele else: elevation_loss += prev_ele - ele prev_ele = ele # Calculate averages avg_heart_rate = sum(heart_rates) / len(heart_rates) if heart_rates else None avg_cadence = sum(cadences) / len(cadences) if cadences else None return { "activityType": {"typeKey": "other"}, "summaryDTO": { "startTime": start_time.isoformat() if 'start_time' in locals() else None, "duration": duration, "distance": total_distance, "elevationGain": elevation_gain, "elevationLoss": elevation_loss, "minElevation": min_elevation, "maxElevation": max_elevation, "maxHR": max(heart_rates) if heart_rates else None, "avgHR": avg_heart_rate, "cadence": avg_cadence, "calories": None # Calories not typically in GPX files } } except Exception as e: logger.error(f"Error parsing GPX file {file_path}: {str(e)}") return None def haversine(lat1, lon1, lat2, lon2): """ Calculate the great circle distance between two points on the earth (specified in decimal degrees) Returns distance in meters """ # Convert decimal degrees to radians lon1, lat1, lon2, lat2 = map(math.radians, [lon1, lat1, lon2, lat2]) # Haversine formula dlon = lon2 - lon1 dlat = lat2 - lat1 a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2 c = 2 * math.asin(math.sqrt(a)) # Radius of earth in meters r = 6371000 return c * r