Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Parser for loading FIT, TCX and GPX activity files into Pandas DataFrames.

Provides a parser object for reading (optionally gzipped) FIT, TCX, and GPX activity files and converting them into Pandas DataFrames.
During import, column names extracted from activity files are normalized into a canonical set of output column names.
See [TCX & GPX files](#tcx--gpx-files) below for supported schema versions/extensions.
See [FIT files](#fit-files) and [TCX & GPX files](#tcx--gpx-files) below for format-specific details.

## Installation

Expand All @@ -34,6 +34,8 @@ records, laps, extra = parser.parse('path/to/gpx_file.gpx')
records, laps, extra = parser.parse('path/to/tcx_file.tcx')
```

Each file is assumed to contain a single activity: multiple activities/tracks (chained FIT files, multi-activity TCX, multi-track GPX) are merged into one set of results, possibly over-writing some fields.

Regardless of source format, `records` and `laps` use a canonical set of column names (see [Output columns](#output-columns) below), e.g.:

```
Expand Down Expand Up @@ -71,6 +73,13 @@ Fields marked with (\*) are exporter-dependent.
`laps` follows the same convention for shared metrics (`total_distance`, `avg_speed`, `max_speed`, `avg_heart_rate`, `avg_power`, `total_calories`, etc.).
FIT also exposes FIT-specific fields not available from TCX/GPX (e.g. `fractional_cadence`, `left_right_balance`, `accumulated_power`) under their native FIT names.

## FIT files

FIT parsing wraps [`fitdecode`](https://github.com/polyvertex/fitdecode), which decodes against Garmin's public FIT SDK profile — field names, types, and units come from that profile, not this package.

- Newer devices sometimes write only `enhanced_altitude`/`enhanced_speed` (and lap `enhanced_avg_speed`/`enhanced_max_speed`); `parse()` coalesces these into `altitude`/`speed` transparently.
- Messages/fields fitdecode can't resolve against the profile (e.g. proprietary extensions) are dropped.

## TCX & GPX files

The parser supports the following schema versions and extensions:
Expand Down
29 changes: 29 additions & 0 deletions src/activity_parser/parse_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ def select_and_rename_cols(
return df.loc[:, cols].rename(columns=mapper)


def coalesce_enhanced_columns(df: pd.DataFrame, pairs: Mapping[str, str]) -> pd.DataFrame:
"""Prefers each enhanced FIT column's values over its base counterpart.

Newer devices sometimes write only the enhanced field (e.g. ``enhanced_altitude``),
leaving the base field (``altitude``) absent entirely; both are already in the same
units, so no conversion is needed. ``pairs`` maps base column name to enhanced
column name.
"""
df = df.copy()
for base, enhanced in pairs.items():
if enhanced not in df.columns:
continue
df[base] = df[enhanced].combine_first(df[base]) if base in df.columns else df[enhanced]
return df


def normalize_extension(ext: str) -> str:
"""Normalize an extension string to one of: fit, tcx, gpx."""
normalized = ext.lower().lstrip(".")
Expand Down Expand Up @@ -188,6 +204,17 @@ def __init__(self, strict_xml: bool = False) -> None:
# Just use the FIT names for lap data as canonical
self.fit_laps_mapper = {}

# Newer devices sometimes write only the higher-precision enhanced_* fields,
# omitting their base counterparts entirely; prefer enhanced where present.
self.fit_records_enhanced_pairs = {
"altitude": "enhanced_altitude",
"speed": "enhanced_speed",
}
self.fit_laps_enhanced_pairs = {
"avg_speed": "enhanced_avg_speed",
"max_speed": "enhanced_max_speed",
}

def parse(
self,
file: str | PathLike[str] | IO[bytes],
Expand Down Expand Up @@ -219,12 +246,14 @@ def parse(

if ext_normalized == "fit":
records, laps, extra = parse_fit(file)
records = coalesce_enhanced_columns(records, self.fit_records_enhanced_pairs)
records = select_and_rename_cols(
records,
self.fit_records_selector,
self.fit_records_mapper,
)
records.rename_axis("time", inplace=True)
laps = coalesce_enhanced_columns(laps, self.fit_laps_enhanced_pairs)
laps = select_and_rename_cols(
laps,
self.fit_laps_selector,
Expand Down
26 changes: 26 additions & 0 deletions tests/test_parse_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from activity_parser import ActivityParser
from activity_parser.parse_activity import (
coalesce_enhanced_columns,
infer_extension,
normalize_extension,
select_and_rename_cols,
Expand Down Expand Up @@ -40,6 +41,31 @@ def test_select_and_rename_cols():
assert out["x"].iloc[0] == 2


def test_coalesce_enhanced_columns_prefers_enhanced_when_both_present():
df = pd.DataFrame({"altitude": [100.0, 200.0], "enhanced_altitude": [105.0, 210.0]})
out = coalesce_enhanced_columns(df, {"altitude": "enhanced_altitude"})
assert list(out["altitude"]) == [105.0, 210.0]


def test_coalesce_enhanced_columns_falls_back_to_base_where_enhanced_is_null():
df = pd.DataFrame({"speed": [10.0, 20.0], "enhanced_speed": [15.0, None]})
out = coalesce_enhanced_columns(df, {"speed": "enhanced_speed"})
assert list(out["speed"]) == [15.0, 20.0]


def test_coalesce_enhanced_columns_fills_missing_base_column():
df = pd.DataFrame({"enhanced_speed": [10.0, 20.0]})
out = coalesce_enhanced_columns(df, {"speed": "enhanced_speed"})
assert list(out["speed"]) == [10.0, 20.0]


def test_coalesce_enhanced_columns_noop_without_enhanced_column():
df = pd.DataFrame({"altitude": [100.0, 200.0]})
out = coalesce_enhanced_columns(df, {"altitude": "enhanced_altitude"})
assert list(out["altitude"]) == [100.0, 200.0]
assert "enhanced_altitude" not in out.columns


def test_parse_file_like_requires_ext():
with pytest.raises(ValueError, match="ext must be provided"):
ActivityParser().parse(io.BytesIO(b""))
Expand Down