Remove fitparse entirely - use Garmin SDK only with messages dict approach
This commit is contained in:
@@ -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 {}
|
||||
Reference in New Issue
Block a user