Remove fitparse entirely - use Garmin SDK only with messages dict approach
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 31s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Successful in 24s

This commit is contained in:
2026-06-06 19:17:51 +01:00
parent e9811d8d83
commit f609931ebc
3 changed files with 119 additions and 208 deletions
+55 -80
View File
@@ -1,24 +1,15 @@
"""
FIT and GPX file parser using:
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
- gpxpy for .gpx files
The official SDK correctly handles scale/offset, component expansion,
semicircle-to-degree conversion, and HR message merging.
FIT and GPX file parser using the official Garmin FIT Python SDK.
"""
import math
from pathlib import Path
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone
from typing import Optional
import gpxpy
import polyline as polyline_lib
FIT_EPOCH_S = 631065600
from garmin_fit_sdk import Decoder, Stream
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
"""Distance in metres between two GPS points."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
@@ -43,26 +34,22 @@ def _bounding_box(coords: list) -> Optional[dict]:
"min_lon": min(lons), "max_lon": max(lons)}
def _ensure_utc(dt) -> Optional[datetime]:
if dt is None:
return None
if isinstance(dt, datetime):
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
return None
def parse_fit_file(filepath: str) -> dict:
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
from garmin_fit_sdk import Decoder, Stream
session = {}
records = []
laps = []
def listener(mesg_num: int, msg: dict):
nonlocal session
if mesg_num == 18: # session
session = msg
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
laps.append(msg)
stream = Stream.from_file(filepath)
decoder = Decoder(stream)
decoder.read(
messages, errors = decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
@@ -70,58 +57,62 @@ def parse_fit_file(filepath: str) -> dict:
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
mesg_listener=listener,
)
# Map sport type
sessions = messages.get("session", [{}])
session = sessions[0] if sessions else {}
records = messages.get("record", [])
laps = messages.get("lap", [])
sport = str(session.get("sport", "generic")).lower()
sport_map = {
"running": "running", "cycling": "cycling", "swimming": "swimming",
"hiking": "hiking", "walking": "walking", "generic": "other",
"open_water_swimming": "swimming", "trail_running": "running",
"e_biking": "cycling",
"running": "running", "cycling": "cycling",
"hiking": "hiking", "walking": "walking",
"generic": "other", "trail_running": "running",
"e_biking": "cycling", "open_water_swimming": "other",
}
sport_type = sport_map.get(sport, sport)
start_time = session.get("start_time")
if isinstance(start_time, datetime) and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
start_time = _ensure_utc(session.get("start_time"))
coords = []
for r in records:
lat = r.get("position_lat")
lon = r.get("position_long")
if lat is not None and lon is not None:
if -90 <= lat <= 90 and -180 <= lon <= 180:
coords.append((lat, lon))
# Build GPS track
coords = [
(r["position_lat"], r["position_long"])
for r in records
if r.get("position_lat") is not None and r.get("position_long") is not None
]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Normalize data points
normalized_points = []
for r in records:
ts = r.get("timestamp")
if isinstance(ts, datetime) and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
ts = _ensure_utc(r.get("timestamp"))
lat = r.get("position_lat")
lon = r.get("position_long")
if lat is not None and not (-90 <= lat <= 90):
lat = None
if lon is not None and not (-180 <= lon <= 180):
lon = None
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": r.get("position_lat"),
"longitude": r.get("position_long"),
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
"heart_rate": r.get("heart_rate"),
"cadence": r.get("cadence") or r.get("fractional_cadence"),
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
"power": r.get("power"),
"temperature_c": r.get("temperature"),
"distance_m": r.get("distance"),
"latitude": _safe_float(lat),
"longitude": _safe_float(lon),
"altitude_m": _safe_float(r.get("altitude") or r.get("enhanced_altitude")),
"heart_rate": _safe_float(r.get("heart_rate")),
"cadence": _safe_float(r.get("cadence")),
"speed_ms": _safe_float(r.get("speed") or r.get("enhanced_speed")),
"power": _safe_float(r.get("power")),
"temperature_c": _safe_float(r.get("temperature")),
"distance_m": _safe_float(r.get("distance")),
})
# Normalize laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = lap.get("start_time")
if isinstance(ls, datetime) and ls.tzinfo is None:
ls = ls.replace(tzinfo=timezone.utc)
ls = _ensure_utc(lap.get("start_time"))
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
@@ -133,8 +124,7 @@ def parse_fit_file(filepath: str) -> dict:
"avg_power": _safe_float(lap.get("avg_power")),
})
# Build activity name
name = session.get("sport", "Activity").title()
name = sport_type.title()
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
@@ -209,7 +199,6 @@ def parse_gpx_file(filepath: str) -> dict:
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Add cumulative distance
total_dist = 0.0
prev = None
for p in data_points:
@@ -219,7 +208,6 @@ def parse_gpx_file(filepath: str) -> dict:
prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist
# Elevation gain/loss
uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)):
@@ -267,20 +255,7 @@ def parse_gpx_file(filepath: str) -> dict:
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
"""
Calculate % time in each HR zone using the user's configured max HR.
Zones follow the standard 5-zone model as % of max HR:
Z1 Recovery: < 60%
Z2 Base: 60 - 70%
Z3 Tempo: 70 - 80%
Z4 Threshold: 80 - 90%
Z5 Max: > 90%
user_max_hr should be the user's actual physiological max HR, NOT the
highest HR recorded in this activity. Using activity max shifts all zones
upward and makes easy runs look harder than they are.
"""
"""Calculate % time in each HR zone using user's configured max HR."""
if not user_max_hr or user_max_hr < 100:
return {}
@@ -300,8 +275,8 @@ def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
zones[key] += 1
break
else:
zones["z5"] += 1 # anything above 90% goes to z5
zones["z5"] += 1
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
return {}