init
This commit is contained in:
102
backend/app/services/fit_parser.py
Normal file
102
backend/app/services/fit_parser.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
|
||||
import fitdecode
|
||||
|
||||
from backend.app.models.activity import Activity, DataPoint
|
||||
|
||||
|
||||
def parse_fit_file(
|
||||
file_content: bytes,
|
||||
rider_id: uuid.UUID,
|
||||
file_path: str,
|
||||
) -> tuple[Activity, list[DataPoint]]:
|
||||
"""Parse a .FIT file and return an Activity with its DataPoints."""
|
||||
data_points: list[DataPoint] = []
|
||||
session_data: dict = {}
|
||||
|
||||
with fitdecode.FitReader(BytesIO(file_content)) as fit:
|
||||
for frame in fit:
|
||||
if not isinstance(frame, fitdecode.FitDataMessage):
|
||||
continue
|
||||
|
||||
if frame.name == "record":
|
||||
dp = _parse_record(frame)
|
||||
if dp:
|
||||
data_points.append(dp)
|
||||
|
||||
elif frame.name == "session":
|
||||
session_data = _parse_session(frame)
|
||||
|
||||
start_time = data_points[0].timestamp if data_points else datetime.now(timezone.utc)
|
||||
end_time = data_points[-1].timestamp if data_points else start_time
|
||||
duration = int((end_time - start_time).total_seconds()) if data_points else 0
|
||||
|
||||
activity = Activity(
|
||||
rider_id=rider_id,
|
||||
name=session_data.get("sport", "Ride"),
|
||||
activity_type=session_data.get("sub_sport", "road"),
|
||||
date=start_time,
|
||||
duration=duration,
|
||||
distance=session_data.get("total_distance"),
|
||||
elevation_gain=session_data.get("total_ascent"),
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
return activity, data_points
|
||||
|
||||
|
||||
def _parse_record(frame: fitdecode.FitDataMessage) -> DataPoint | None:
|
||||
"""Parse a single record message into a DataPoint."""
|
||||
timestamp = _get_field(frame, "timestamp")
|
||||
if not timestamp:
|
||||
return None
|
||||
|
||||
if isinstance(timestamp, datetime) and timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
return DataPoint(
|
||||
timestamp=timestamp,
|
||||
power=_get_field(frame, "power"),
|
||||
heart_rate=_get_field(frame, "heart_rate"),
|
||||
cadence=_get_field(frame, "cadence"),
|
||||
speed=_get_field(frame, "speed"),
|
||||
latitude=_semicircles_to_degrees(_get_field(frame, "position_lat")),
|
||||
longitude=_semicircles_to_degrees(_get_field(frame, "position_long")),
|
||||
altitude=_get_field(frame, "altitude"),
|
||||
temperature=_get_field(frame, "temperature"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_session(frame: fitdecode.FitDataMessage) -> dict:
|
||||
"""Extract session-level data from FIT session message."""
|
||||
return {
|
||||
"sport": _get_field_str(frame, "sport"),
|
||||
"sub_sport": _get_field_str(frame, "sub_sport"),
|
||||
"total_distance": _get_field(frame, "total_distance"),
|
||||
"total_ascent": _get_field(frame, "total_ascent"),
|
||||
"total_elapsed_time": _get_field(frame, "total_elapsed_time"),
|
||||
}
|
||||
|
||||
|
||||
def _get_field(frame: fitdecode.FitDataMessage, name: str):
|
||||
"""Safely get a field value from a FIT frame."""
|
||||
try:
|
||||
field = frame.get_field(name)
|
||||
return field.value if field else None
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_field_str(frame: fitdecode.FitDataMessage, name: str) -> str | None:
|
||||
"""Get field value as string."""
|
||||
val = _get_field(frame, name)
|
||||
return str(val) if val is not None else None
|
||||
|
||||
|
||||
def _semicircles_to_degrees(semicircles: int | None) -> float | None:
|
||||
"""Convert Garmin semicircles to decimal degrees."""
|
||||
if semicircles is None:
|
||||
return None
|
||||
return semicircles * (180.0 / 2**31)
|
||||
Reference in New Issue
Block a user