mirror of
https://github.com/sstent/FitTrack_ReportGenerator.git
synced 2026-01-29 18:41:59 +00:00
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.
154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
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
|