mirror of
https://github.com/sstent/FitTrack_ReportGenerator.git
synced 2026-01-30 19:11:45 +00:00
feat: Initial implementation of FitTrack Report Generator
This commit introduces the initial version of the FitTrack Report Generator, a FastAPI application for analyzing workout files. Key features include: - Parsing of FIT, TCX, and GPX workout files. - Analysis of power, heart rate, speed, and elevation data. - Generation of summary reports and charts. - REST API for single and batch workout analysis. The project structure has been set up with a `src` directory for core logic, an `api` directory for the FastAPI application, and a `tests` directory for unit, integration, and contract tests. The development workflow is configured to use Docker and modern Python tooling.
This commit is contained in:
153
examples/GarminSync/garminsync/parsers/gpx_parser.py
Normal file
153
examples/GarminSync/garminsync/parsers/gpx_parser.py
Normal file
@@ -0,0 +1,153 @@
|
||||
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
|
||||
Reference in New Issue
Block a user