backend: initial commit
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""FIT file parser (Garmin binary format)."""
|
||||
|
||||
from datetime import timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fitdecode
|
||||
|
||||
from bincio.extract.models import DataPoint, LapData, ParsedActivity
|
||||
from bincio.extract.sport import normalise_sport
|
||||
|
||||
|
||||
class FitParser:
|
||||
def parse(self, path: Path, raw_bytes: bytes) -> ParsedActivity:
|
||||
import io
|
||||
|
||||
points: list[DataPoint] = []
|
||||
laps: list[LapData] = []
|
||||
sport: str = "cycling"
|
||||
sub_sport: str | None = None
|
||||
device: str | None = None
|
||||
|
||||
with fitdecode.FitReader(io.BytesIO(raw_bytes)) as fit:
|
||||
for frame in fit:
|
||||
if not isinstance(frame, fitdecode.FitDataMessage):
|
||||
continue
|
||||
|
||||
if frame.name == "sport":
|
||||
sport = normalise_sport(_get(frame, "sport", "cycling"))
|
||||
sub_sport = _normalise_sub_sport(_get(frame, "sub_sport"))
|
||||
|
||||
elif frame.name == "device_info":
|
||||
mfr = _get(frame, "manufacturer")
|
||||
prod = _get(frame, "product_name") or _get(frame, "garmin_product")
|
||||
if mfr and prod:
|
||||
device = f"{mfr} {prod}"
|
||||
elif prod:
|
||||
device = str(prod)
|
||||
|
||||
elif frame.name == "record":
|
||||
ts = _get(frame, "timestamp")
|
||||
if ts is None:
|
||||
continue
|
||||
if hasattr(ts, "tzinfo") and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
lat = _semicircles_to_deg(_get(frame, "position_lat"))
|
||||
lon = _semicircles_to_deg(_get(frame, "position_long"))
|
||||
speed_raw = _get(frame, "speed") # m/s
|
||||
|
||||
dp = DataPoint(
|
||||
timestamp=ts,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
elevation_m=_get(frame, "altitude"),
|
||||
hr_bpm=_get(frame, "heart_rate"),
|
||||
cadence_rpm=_get(frame, "cadence"),
|
||||
speed_kmh=speed_raw * 3.6 if speed_raw is not None else None,
|
||||
power_w=_get(frame, "power"),
|
||||
temperature_c=_get(frame, "temperature"),
|
||||
distance_m=_get(frame, "distance"),
|
||||
)
|
||||
points.append(dp)
|
||||
|
||||
elif frame.name == "lap":
|
||||
ts = _get(frame, "start_time")
|
||||
if ts is not None:
|
||||
if hasattr(ts, "tzinfo") and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
elapsed = _get(frame, "total_elapsed_time")
|
||||
speed_raw = _get(frame, "avg_speed")
|
||||
laps.append(
|
||||
LapData(
|
||||
index=len(laps),
|
||||
started_at=ts,
|
||||
duration_s=int(elapsed) if elapsed else None,
|
||||
distance_m=_get(frame, "total_distance"),
|
||||
elevation_gain_m=_get(frame, "total_ascent"),
|
||||
avg_speed_kmh=speed_raw * 3.6 if speed_raw else None,
|
||||
avg_hr_bpm=_get(frame, "avg_heart_rate"),
|
||||
avg_power_w=_get(frame, "avg_power"),
|
||||
)
|
||||
)
|
||||
|
||||
if not points:
|
||||
raise ValueError(f"No record messages found in {path.name}")
|
||||
|
||||
return ParsedActivity(
|
||||
points=points,
|
||||
sport=sport,
|
||||
sub_sport=sub_sport,
|
||||
started_at=points[0].timestamp,
|
||||
device=device,
|
||||
laps=laps,
|
||||
source_file=path.name,
|
||||
source_hash="",
|
||||
)
|
||||
|
||||
|
||||
def _get(frame: fitdecode.FitDataMessage, field: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return frame.get_value(field)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
|
||||
def _semicircles_to_deg(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
deg = float(value) * (180.0 / 2**31)
|
||||
# Sanity check: invalid semicircle values often come out as ±180+
|
||||
if abs(deg) > 180:
|
||||
return None
|
||||
return deg
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalise_sub_sport(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).lower().replace(" ", "_")
|
||||
mapping = {
|
||||
"road": "road",
|
||||
"mountain": "mountain",
|
||||
"gravel_cycling": "gravel",
|
||||
"cyclocross": "gravel",
|
||||
"indoor_cycling": "indoor",
|
||||
"trail": "trail",
|
||||
"track": "track",
|
||||
}
|
||||
return mapping.get(s, s) or None
|
||||
Reference in New Issue
Block a user