activity-parser 0.9.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {activity_parser-0.9.0 → activity_parser-0.11.0}/.pre-commit-config.yaml +1 -1
  2. activity_parser-0.11.0/PKG-INFO +200 -0
  3. activity_parser-0.11.0/README.md +177 -0
  4. {activity_parser-0.9.0 → activity_parser-0.11.0}/pyproject.toml +2 -2
  5. {activity_parser-0.9.0 → activity_parser-0.11.0}/src/activity_parser/__init__.py +8 -1
  6. activity_parser-0.11.0/src/activity_parser/exceptions.py +13 -0
  7. activity_parser-0.11.0/src/activity_parser/fit_fields.py +128 -0
  8. activity_parser-0.11.0/src/activity_parser/output.py +79 -0
  9. activity_parser-0.11.0/src/activity_parser/parse_activity.py +146 -0
  10. activity_parser-0.11.0/src/activity_parser/parse_fit_file.py +342 -0
  11. activity_parser-0.11.0/src/activity_parser/parse_tcx_gpx.py +259 -0
  12. activity_parser-0.11.0/src/activity_parser/postprocess.py +50 -0
  13. activity_parser-0.11.0/src/activity_parser/xml_fields.py +219 -0
  14. activity_parser-0.11.0/tests/files/README.md +16 -0
  15. activity_parser-0.11.0/tests/files/fit/DeveloperData.fit +0 -0
  16. activity_parser-0.11.0/tests/files/fit/garmin-fenix-5-run.fit +0 -0
  17. activity_parser-0.11.0/tests/files/gpx/gpx10.gpx +30 -0
  18. activity_parser-0.11.0/tests/files/gpx/gpxdata.gpx +32 -0
  19. activity_parser-0.11.0/tests/files/gpx/gpxtpx_v1.gpx +38 -0
  20. activity_parser-0.11.0/tests/files/gpx/metadata.gpx +18 -0
  21. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/mixed_offset.gpx +11 -4
  22. activity_parser-0.11.0/tests/files/gpx/multi_track.gpx +21 -0
  23. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/no_time.gpx +14 -5
  24. activity_parser-0.11.0/tests/files/gpx/route_no_time.gpx +18 -0
  25. activity_parser-0.11.0/tests/files/gpx/unknown_extension.gpx +25 -0
  26. activity_parser-0.11.0/tests/files/tcx/activity_metadata.tcx +19 -0
  27. activity_parser-0.11.0/tests/files/tcx/cadence_collision.tcx +33 -0
  28. activity_parser-0.11.0/tests/files/tcx/duplicate_cadence.tcx +22 -0
  29. activity_parser-0.11.0/tests/files/tcx/laps_only.tcx +14 -0
  30. activity_parser-0.11.0/tests/files/tcx/lx_namespace_reset.tcx +26 -0
  31. activity_parser-0.11.0/tests/files/tcx/mixed_content.tcx +20 -0
  32. activity_parser-0.11.0/tests/files/tcx/multi_activity.tcx +32 -0
  33. activity_parser-0.11.0/tests/files/tcx/no_activity.tcx +9 -0
  34. activity_parser-0.11.0/tests/files/tcx/running.tcx +62 -0
  35. activity_parser-0.11.0/tests/files/tcx/unknown_collision.tcx +26 -0
  36. activity_parser-0.11.0/tests/files/tcx/vendor_type_attribute.tcx +22 -0
  37. activity_parser-0.11.0/tests/synthetic_fit.py +229 -0
  38. activity_parser-0.11.0/tests/test_fit_fields.py +152 -0
  39. activity_parser-0.11.0/tests/test_parse_activity.py +108 -0
  40. activity_parser-0.11.0/tests/test_parse_fit.py +673 -0
  41. activity_parser-0.11.0/tests/test_parse_tcx_gpx.py +414 -0
  42. activity_parser-0.11.0/tests/test_postprocess.py +50 -0
  43. activity_parser-0.11.0/tests/test_readme.py +44 -0
  44. activity_parser-0.11.0/tests/test_xml_fields.py +59 -0
  45. activity_parser-0.9.0/PKG-INFO +0 -97
  46. activity_parser-0.9.0/README.md +0 -74
  47. activity_parser-0.9.0/src/activity_parser/parse_activity.py +0 -281
  48. activity_parser-0.9.0/src/activity_parser/parse_fit_file.py +0 -105
  49. activity_parser-0.9.0/src/activity_parser/parse_tcx_gpx.py +0 -201
  50. activity_parser-0.9.0/tests/files/README.md +0 -10
  51. activity_parser-0.9.0/tests/test_parse_activity.py +0 -60
  52. activity_parser-0.9.0/tests/test_parse_fit.py +0 -99
  53. activity_parser-0.9.0/tests/test_parse_tcx_gpx.py +0 -212
  54. {activity_parser-0.9.0 → activity_parser-0.11.0}/.github/workflows/ci.yml +0 -0
  55. {activity_parser-0.9.0 → activity_parser-0.11.0}/.github/workflows/release.yml +0 -0
  56. {activity_parser-0.9.0 → activity_parser-0.11.0}/.gitignore +0 -0
  57. {activity_parser-0.9.0 → activity_parser-0.11.0}/LICENSE +0 -0
  58. {activity_parser-0.9.0 → activity_parser-0.11.0}/src/activity_parser/py.typed +0 -0
  59. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/fit}/garmin-edge-820-bike.fit +0 -0
  60. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/fit}/garmin-fenix-5-bike.fit +0 -0
  61. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/bad_speed.gpx +0 -0
  62. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/multi_segment.gpx +0 -0
  63. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/sample.gpx +0 -0
  64. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/mixed_offset.tcx +0 -0
  65. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/no_position.tcx +0 -0
  66. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/no_time.tcx +0 -0
  67. {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/sample.tcx +0 -0
@@ -11,7 +11,7 @@ repos:
11
11
  name: ruff format
12
12
  entry: uv run ruff format
13
13
  language: system
14
- types: [python]
14
+ types_or: [python, markdown]
15
15
 
16
16
  - id: pyright
17
17
  name: pyright
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.5
2
+ Name: activity-parser
3
+ Version: 0.11.0
4
+ Summary: Parser for loading FIT, TCX and GPX files into Pandas DataFrames.
5
+ Project-URL: Repository, https://github.com/tabishm52/activity_parser
6
+ Project-URL: Issues, https://github.com/tabishm52/activity_parser/issues
7
+ Author-email: Tabish Mustufa <tabishm@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: fit,fitness,gps,gpx,pandas,parser,tcx
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Scientific/Engineering :: GIS
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: garmin-fit-sdk
20
+ Requires-Dist: lxml
21
+ Requires-Dist: pandas
22
+ Description-Content-Type: text/markdown
23
+
24
+ # activity-parser
25
+
26
+ [![CI](https://github.com/tabishm52/activity_parser/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tabishm52/activity_parser/actions/workflows/ci.yml)
27
+ [![PyPI](https://img.shields.io/pypi/v/activity-parser)](https://pypi.org/project/activity-parser/)
28
+ [![Python versions](https://img.shields.io/pypi/pyversions/activity-parser)](https://pypi.org/project/activity-parser/)
29
+ [![License](https://img.shields.io/pypi/l/activity-parser)](https://pypi.org/project/activity-parser/)
30
+
31
+ Parser for loading FIT, TCX and GPX activity files into Pandas DataFrames.
32
+
33
+ Provides a parser object for reading (optionally gzipped) FIT, TCX, and GPX activity files and converting them into Pandas DataFrames.
34
+ During import, field names extracted from activity files are normalized into a canonical set of output column names with consistent units to allow for standardized downstream data processing.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install activity-parser
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ Create a new instance of the `ActivityParser` class to be reused for subsequent parsing of activity files:
45
+
46
+ ```python
47
+ import activity_parser
48
+
49
+ parser = activity_parser.ActivityParser()
50
+ ```
51
+
52
+ Parse FIT, TCX and GPX files into normalized DataFrames:
53
+
54
+ ```python
55
+ from activity_parser import ParseError
56
+
57
+ try:
58
+ records, laps, activity = parser.parse("path/to/fit_file.fit")
59
+ records, laps, activity = parser.parse("path/to/gpx_file.gpx")
60
+ records, laps, activity = parser.parse("path/to/tcx_file.tcx")
61
+ except ParseError as e:
62
+ ...
63
+ ```
64
+
65
+ Each file is assumed to contain a single activity.
66
+ Multiple activities/tracks (chained FIT files, multi-activity TCX, multi-track GPX) have their records/laps merged into one set of results.
67
+ In those cases, the returned `Activity` summary reflects only the first activity/session in the file.
68
+
69
+ `parse()` raises `ParseError` (`FitError` for FIT files, `XmlError` for TCX/GPX) when a file fails to parse.
70
+ It raises `ValueError` for an unrecognized or ambiguous file type.
71
+
72
+ ## Output data
73
+
74
+ `records` and `laps` are each a DataFrame of normalized data, e.g.:
75
+
76
+ ```
77
+ distance speed cadence heart_rate
78
+ time
79
+ 2026-01-05 08:00:00+00:00 0.00 36.0 80 100
80
+ 2026-01-05 08:00:01+00:00 0.01 36.0 81 101
81
+ ```
82
+
83
+ A few notes on column handling:
84
+
85
+ - Column names and units are standardized across source formats for known field types.
86
+ - Unknown fields are omitted by default; customize columns via `record_columns`, `lap_columns`, and `include_all_columns` on `ActivityParser` — see its docstring for details.
87
+ - Not every column appears in every file: `parse()` only includes columns actually present.
88
+
89
+ ### Records
90
+
91
+ Not all fields are available in all source formats.
92
+ Fields marked with (\*) are exporter-dependent.
93
+
94
+ | Column | Unit | FIT | TCX | GPX |
95
+ |---|---|:-:|:-:|:-:|
96
+ | `latitude`, `longitude` | degrees | Yes | Yes | Yes |
97
+ | `altitude` | meters | Yes | Yes | Yes |
98
+ | `distance` | km | Yes | Yes | (\*) |
99
+ | `speed` | km/h | Yes | (\*) | (\*) |
100
+ | `cadence` | rpm | Yes | Yes | (\*) |
101
+ | `heart_rate` | bpm | Yes | Yes | (\*) |
102
+ | `power` | watts | Yes | (\*) | (\*) |
103
+ | `left_balance`, `right_balance` | percent | Yes | — | — |
104
+ | `accumulated_power` | watts | Yes | — | — |
105
+ | `temperature` | °C | Yes | — | (\*) |
106
+ | `water_temperature` | °C | — | — | (\*) |
107
+ | `depth` | meters | — | — | (\*) |
108
+ | `course`, `bearing` | degrees | — | — | (\*) |
109
+
110
+ ### Laps
111
+
112
+ GPX files have no lap data, so `laps` is always an empty DataFrame for GPX.
113
+ Fields marked with (\*) are exporter-dependent.
114
+
115
+ | Column | Unit | FIT | TCX |
116
+ |---|---|:-:|:-:|
117
+ | `start_time` | timestamp | Yes | Yes |
118
+ | `total_elapsed_time` | seconds | Yes | Yes |
119
+ | `total_timer_time` | seconds | Yes | — |
120
+ | `start_position_lat`, `start_position_long` | degrees | Yes | — |
121
+ | `end_position_lat`, `end_position_long` | degrees | Yes | — |
122
+ | `total_distance` | km | Yes | Yes |
123
+ | `total_ascent`, `total_descent` | meters | Yes | — |
124
+ | `avg_vam` | m/s | Yes | — |
125
+ | `avg_speed` | km/h | Yes | (\*) |
126
+ | `max_speed` | km/h | Yes | Yes |
127
+ | `avg_cadence` | rpm | Yes | Yes |
128
+ | `max_cadence` | rpm | Yes | (\*) |
129
+ | `total_strokes` | count | Yes | — |
130
+ | `steps` | count | — | (\*) |
131
+ | `avg_heart_rate`, `max_heart_rate` | bpm | Yes | Yes |
132
+ | `time_in_hr_zone` | seconds | Yes | — |
133
+ | `avg_power`, `max_power` | watts | Yes | (\*) |
134
+ | `normalized_power` | watts | Yes | — |
135
+ | `left_balance`, `right_balance` | percent | Yes | — |
136
+ | `time_in_power_zone` | seconds | Yes | — |
137
+ | `total_work` | joules | Yes | — |
138
+ | `avg_temperature`, `max_temperature` | °C | Yes | — |
139
+ | `total_calories` | kcal | Yes | Yes |
140
+ | `total_fat_calories` | kcal | Yes | — |
141
+
142
+ ### Activity
143
+
144
+ `activity` is an `Activity` dataclass instance: a small file-level summary.
145
+ Fields are `None` when the source format/file doesn't record them.
146
+ Values come from the file's own summary fields, never computed/derived from records or laps.
147
+
148
+ | Field | Unit | FIT | TCX | GPX |
149
+ |---|---|:-:|:-:|:-:|
150
+ | `sport` | — | Yes | Yes | — |
151
+ | `start_time` | — | Yes | Yes | Yes (\*) |
152
+ | `total_elapsed_time` | seconds | Yes | — | — |
153
+ | `total_distance` | km | Yes | — | — |
154
+ | `total_calories` | kcal | Yes | — | — |
155
+ | `avg_heart_rate`, `max_heart_rate` | bpm | Yes | — | — |
156
+ | `avg_speed`, `max_speed` | km/h | Yes | — | — |
157
+ | `creator` | — | Yes | Yes | Yes |
158
+ | `notes` | — | — | Yes | Yes |
159
+
160
+ (\*) GPX's `start_time` comes from `metadata/time`, which is technically the file's export time rather than the activity's start.
161
+
162
+ ## Parser notes
163
+
164
+ ### FIT files
165
+
166
+ FIT parsing wraps [`garmin-fit-sdk`](https://pypi.org/project/garmin-fit-sdk/), Garmin's official SDK, which decodes against the public FIT SDK profile.
167
+
168
+ `parse()` transforms certain FIT fields beyond decoding:
169
+
170
+ - Positions are converted to degrees, distances to km, and speeds to km/h.
171
+ Vertical rates (`avg_vam`, `vertical_speed`, and similar) stay in m/s.
172
+ - For fields with lower- and higher-precision versions, only the higher-precision value is returned, under the base field's name.
173
+ - For fields with sub-integer precision in a separate field, the precision is added into the base field.
174
+ - `left_right_balance` (a bit-packed field) is decoded into `left_balance`/`right_balance`.
175
+ - `heart_rate` is merged with a higher-rate `hr` stream when the file has one.
176
+ Records outside its coverage keep their own device value.
177
+
178
+ Messages/fields that can't be resolved against the FIT profile (e.g. proprietary extensions) are kept as raw values under `unknown_<n>` names.
179
+ Developer fields are resolved to their file-embedded names and flattened into ordinary columns alongside built-in fields.
180
+
181
+ `parse_fit` and `parse_fit_raw` are the lower-level functions `ActivityParser.parse()` wraps for FIT files.
182
+ `parse_fit` returns the same canonical `(records, laps, activity)` shape, but without column curation.
183
+ `parse_fit_raw` returns every message type in the file as its own uncurated DataFrame, in FIT-native units (semicircles, meters, m/s).
184
+
185
+ ### TCX & GPX files
186
+
187
+ TCX and GPX files are parsed natively in this package using [`lxml`](https://pypi.org/project/lxml/).
188
+ Recognized elements and attributes are mapped onto the same canonical column names as FIT, with the same unit conventions.
189
+
190
+ The parser supports the following schema versions and extensions:
191
+
192
+ | Format | Schema version | Extensions |
193
+ |---|---|---|
194
+ | TCX | v2 | Garmin `ActivityExtension` v2 |
195
+ | GPX | 1.0 / 1.1 | Garmin `TrackPointExtension` v1/v2, Cluetrust `gpxdata` |
196
+
197
+ Unrecognized elements and attributes are exported as string columns named with their XML namespace in Clark notation.
198
+
199
+ `parse_gpx` and `parse_tcx` are the lower-level functions `ActivityParser.parse()` wraps for these formats.
200
+ They return the same canonical `(records, laps, activity)` shape, but without column curation.
@@ -0,0 +1,177 @@
1
+ # activity-parser
2
+
3
+ [![CI](https://github.com/tabishm52/activity_parser/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/tabishm52/activity_parser/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/activity-parser)](https://pypi.org/project/activity-parser/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/activity-parser)](https://pypi.org/project/activity-parser/)
6
+ [![License](https://img.shields.io/pypi/l/activity-parser)](https://pypi.org/project/activity-parser/)
7
+
8
+ Parser for loading FIT, TCX and GPX activity files into Pandas DataFrames.
9
+
10
+ Provides a parser object for reading (optionally gzipped) FIT, TCX, and GPX activity files and converting them into Pandas DataFrames.
11
+ During import, field names extracted from activity files are normalized into a canonical set of output column names with consistent units to allow for standardized downstream data processing.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install activity-parser
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Create a new instance of the `ActivityParser` class to be reused for subsequent parsing of activity files:
22
+
23
+ ```python
24
+ import activity_parser
25
+
26
+ parser = activity_parser.ActivityParser()
27
+ ```
28
+
29
+ Parse FIT, TCX and GPX files into normalized DataFrames:
30
+
31
+ ```python
32
+ from activity_parser import ParseError
33
+
34
+ try:
35
+ records, laps, activity = parser.parse("path/to/fit_file.fit")
36
+ records, laps, activity = parser.parse("path/to/gpx_file.gpx")
37
+ records, laps, activity = parser.parse("path/to/tcx_file.tcx")
38
+ except ParseError as e:
39
+ ...
40
+ ```
41
+
42
+ Each file is assumed to contain a single activity.
43
+ Multiple activities/tracks (chained FIT files, multi-activity TCX, multi-track GPX) have their records/laps merged into one set of results.
44
+ In those cases, the returned `Activity` summary reflects only the first activity/session in the file.
45
+
46
+ `parse()` raises `ParseError` (`FitError` for FIT files, `XmlError` for TCX/GPX) when a file fails to parse.
47
+ It raises `ValueError` for an unrecognized or ambiguous file type.
48
+
49
+ ## Output data
50
+
51
+ `records` and `laps` are each a DataFrame of normalized data, e.g.:
52
+
53
+ ```
54
+ distance speed cadence heart_rate
55
+ time
56
+ 2026-01-05 08:00:00+00:00 0.00 36.0 80 100
57
+ 2026-01-05 08:00:01+00:00 0.01 36.0 81 101
58
+ ```
59
+
60
+ A few notes on column handling:
61
+
62
+ - Column names and units are standardized across source formats for known field types.
63
+ - Unknown fields are omitted by default; customize columns via `record_columns`, `lap_columns`, and `include_all_columns` on `ActivityParser` — see its docstring for details.
64
+ - Not every column appears in every file: `parse()` only includes columns actually present.
65
+
66
+ ### Records
67
+
68
+ Not all fields are available in all source formats.
69
+ Fields marked with (\*) are exporter-dependent.
70
+
71
+ | Column | Unit | FIT | TCX | GPX |
72
+ |---|---|:-:|:-:|:-:|
73
+ | `latitude`, `longitude` | degrees | Yes | Yes | Yes |
74
+ | `altitude` | meters | Yes | Yes | Yes |
75
+ | `distance` | km | Yes | Yes | (\*) |
76
+ | `speed` | km/h | Yes | (\*) | (\*) |
77
+ | `cadence` | rpm | Yes | Yes | (\*) |
78
+ | `heart_rate` | bpm | Yes | Yes | (\*) |
79
+ | `power` | watts | Yes | (\*) | (\*) |
80
+ | `left_balance`, `right_balance` | percent | Yes | — | — |
81
+ | `accumulated_power` | watts | Yes | — | — |
82
+ | `temperature` | °C | Yes | — | (\*) |
83
+ | `water_temperature` | °C | — | — | (\*) |
84
+ | `depth` | meters | — | — | (\*) |
85
+ | `course`, `bearing` | degrees | — | — | (\*) |
86
+
87
+ ### Laps
88
+
89
+ GPX files have no lap data, so `laps` is always an empty DataFrame for GPX.
90
+ Fields marked with (\*) are exporter-dependent.
91
+
92
+ | Column | Unit | FIT | TCX |
93
+ |---|---|:-:|:-:|
94
+ | `start_time` | timestamp | Yes | Yes |
95
+ | `total_elapsed_time` | seconds | Yes | Yes |
96
+ | `total_timer_time` | seconds | Yes | — |
97
+ | `start_position_lat`, `start_position_long` | degrees | Yes | — |
98
+ | `end_position_lat`, `end_position_long` | degrees | Yes | — |
99
+ | `total_distance` | km | Yes | Yes |
100
+ | `total_ascent`, `total_descent` | meters | Yes | — |
101
+ | `avg_vam` | m/s | Yes | — |
102
+ | `avg_speed` | km/h | Yes | (\*) |
103
+ | `max_speed` | km/h | Yes | Yes |
104
+ | `avg_cadence` | rpm | Yes | Yes |
105
+ | `max_cadence` | rpm | Yes | (\*) |
106
+ | `total_strokes` | count | Yes | — |
107
+ | `steps` | count | — | (\*) |
108
+ | `avg_heart_rate`, `max_heart_rate` | bpm | Yes | Yes |
109
+ | `time_in_hr_zone` | seconds | Yes | — |
110
+ | `avg_power`, `max_power` | watts | Yes | (\*) |
111
+ | `normalized_power` | watts | Yes | — |
112
+ | `left_balance`, `right_balance` | percent | Yes | — |
113
+ | `time_in_power_zone` | seconds | Yes | — |
114
+ | `total_work` | joules | Yes | — |
115
+ | `avg_temperature`, `max_temperature` | °C | Yes | — |
116
+ | `total_calories` | kcal | Yes | Yes |
117
+ | `total_fat_calories` | kcal | Yes | — |
118
+
119
+ ### Activity
120
+
121
+ `activity` is an `Activity` dataclass instance: a small file-level summary.
122
+ Fields are `None` when the source format/file doesn't record them.
123
+ Values come from the file's own summary fields, never computed/derived from records or laps.
124
+
125
+ | Field | Unit | FIT | TCX | GPX |
126
+ |---|---|:-:|:-:|:-:|
127
+ | `sport` | — | Yes | Yes | — |
128
+ | `start_time` | — | Yes | Yes | Yes (\*) |
129
+ | `total_elapsed_time` | seconds | Yes | — | — |
130
+ | `total_distance` | km | Yes | — | — |
131
+ | `total_calories` | kcal | Yes | — | — |
132
+ | `avg_heart_rate`, `max_heart_rate` | bpm | Yes | — | — |
133
+ | `avg_speed`, `max_speed` | km/h | Yes | — | — |
134
+ | `creator` | — | Yes | Yes | Yes |
135
+ | `notes` | — | — | Yes | Yes |
136
+
137
+ (\*) GPX's `start_time` comes from `metadata/time`, which is technically the file's export time rather than the activity's start.
138
+
139
+ ## Parser notes
140
+
141
+ ### FIT files
142
+
143
+ FIT parsing wraps [`garmin-fit-sdk`](https://pypi.org/project/garmin-fit-sdk/), Garmin's official SDK, which decodes against the public FIT SDK profile.
144
+
145
+ `parse()` transforms certain FIT fields beyond decoding:
146
+
147
+ - Positions are converted to degrees, distances to km, and speeds to km/h.
148
+ Vertical rates (`avg_vam`, `vertical_speed`, and similar) stay in m/s.
149
+ - For fields with lower- and higher-precision versions, only the higher-precision value is returned, under the base field's name.
150
+ - For fields with sub-integer precision in a separate field, the precision is added into the base field.
151
+ - `left_right_balance` (a bit-packed field) is decoded into `left_balance`/`right_balance`.
152
+ - `heart_rate` is merged with a higher-rate `hr` stream when the file has one.
153
+ Records outside its coverage keep their own device value.
154
+
155
+ Messages/fields that can't be resolved against the FIT profile (e.g. proprietary extensions) are kept as raw values under `unknown_<n>` names.
156
+ Developer fields are resolved to their file-embedded names and flattened into ordinary columns alongside built-in fields.
157
+
158
+ `parse_fit` and `parse_fit_raw` are the lower-level functions `ActivityParser.parse()` wraps for FIT files.
159
+ `parse_fit` returns the same canonical `(records, laps, activity)` shape, but without column curation.
160
+ `parse_fit_raw` returns every message type in the file as its own uncurated DataFrame, in FIT-native units (semicircles, meters, m/s).
161
+
162
+ ### TCX & GPX files
163
+
164
+ TCX and GPX files are parsed natively in this package using [`lxml`](https://pypi.org/project/lxml/).
165
+ Recognized elements and attributes are mapped onto the same canonical column names as FIT, with the same unit conventions.
166
+
167
+ The parser supports the following schema versions and extensions:
168
+
169
+ | Format | Schema version | Extensions |
170
+ |---|---|---|
171
+ | TCX | v2 | Garmin `ActivityExtension` v2 |
172
+ | GPX | 1.0 / 1.1 | Garmin `TrackPointExtension` v1/v2, Cluetrust `gpxdata` |
173
+
174
+ Unrecognized elements and attributes are exported as string columns named with their XML namespace in Clark notation.
175
+
176
+ `parse_gpx` and `parse_tcx` are the lower-level functions `ActivityParser.parse()` wraps for these formats.
177
+ They return the same canonical `(records, laps, activity)` shape, but without column curation.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "activity-parser"
7
- version = "0.9.0"
7
+ version = "0.11.0"
8
8
  description = "Parser for loading FIT, TCX and GPX files into Pandas DataFrames."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,7 +26,7 @@ requires-python = ">=3.11"
26
26
  dependencies = [
27
27
  "pandas",
28
28
  "lxml",
29
- "fitdecode",
29
+ "garmin-fit-sdk",
30
30
  ]
31
31
 
32
32
  [project.urls]
@@ -1,12 +1,19 @@
1
1
  """Parser for loading FIT, TCX and GPX files into Pandas DataFrames."""
2
2
 
3
+ from .exceptions import FitError, ParseError, XmlError
4
+ from .output import Activity
3
5
  from .parse_activity import ActivityParser
4
- from .parse_fit_file import parse_fit
6
+ from .parse_fit_file import parse_fit, parse_fit_raw
5
7
  from .parse_tcx_gpx import parse_gpx, parse_tcx
6
8
 
7
9
  __all__ = [
10
+ "Activity",
8
11
  "ActivityParser",
12
+ "FitError",
13
+ "ParseError",
14
+ "XmlError",
9
15
  "parse_fit",
16
+ "parse_fit_raw",
10
17
  "parse_gpx",
11
18
  "parse_tcx",
12
19
  ]
@@ -0,0 +1,13 @@
1
+ """Parsing exceptions, decoupled from the decoding backend."""
2
+
3
+
4
+ class ParseError(Exception):
5
+ """Base for parsing failures across FIT, TCX, and GPX activity files."""
6
+
7
+
8
+ class FitError(ParseError):
9
+ """Raised when a FIT file fails to parse (e.g. invalid header, CRC mismatch)."""
10
+
11
+
12
+ class XmlError(ParseError):
13
+ """Raised when a TCX or GPX file fails to parse due to malformed XML."""
@@ -0,0 +1,128 @@
1
+ """Unit conversions applied to known FIT fields, and the tables driving them.
2
+
3
+ FIT stores positions in semicircles, distances in meters and speeds in m/s. This
4
+ package normalizes those to degrees, km and km/h. Vertical rates (``avg_vam``,
5
+ ``vertical_speed``, ...) are left in m/s.
6
+
7
+ The tables are transcribed from the FIT profile in garmin-fit-sdk 21.212.0, covering
8
+ the ``record``, ``lap`` and ``session`` message types. ``tests/test_fit_fields.py``
9
+ checks that the installed profile's fields are covered.
10
+ """
11
+
12
+ import math
13
+ from collections.abc import Mapping
14
+ from typing import Any
15
+
16
+ import pandas as pd
17
+
18
+ from .postprocess import coerce_numeric_all_or_nothing
19
+
20
+ SEMICIRCLES_TO_DEGREES = 180.0 / 2**31
21
+ M_TO_KM = 1 / 1000
22
+ MPS_TO_KMH = 3.6
23
+
24
+ RECORD_UNITS: dict[str, float] = {
25
+ "position_lat": SEMICIRCLES_TO_DEGREES,
26
+ "position_long": SEMICIRCLES_TO_DEGREES,
27
+ "distance": M_TO_KM,
28
+ "ball_speed": MPS_TO_KMH,
29
+ "enhanced_speed": MPS_TO_KMH,
30
+ "speed": MPS_TO_KMH,
31
+ "speed_1s": MPS_TO_KMH,
32
+ }
33
+
34
+ LAP_UNITS: dict[str, float] = {
35
+ "end_position_lat": SEMICIRCLES_TO_DEGREES,
36
+ "end_position_long": SEMICIRCLES_TO_DEGREES,
37
+ "start_position_lat": SEMICIRCLES_TO_DEGREES,
38
+ "start_position_long": SEMICIRCLES_TO_DEGREES,
39
+ "avg_stroke_distance": M_TO_KM,
40
+ "total_distance": M_TO_KM,
41
+ "avg_speed": MPS_TO_KMH,
42
+ "enhanced_avg_speed": MPS_TO_KMH,
43
+ "enhanced_max_speed": MPS_TO_KMH,
44
+ "max_speed": MPS_TO_KMH,
45
+ }
46
+
47
+ SESSION_UNITS: dict[str, float] = {
48
+ "end_position_lat": SEMICIRCLES_TO_DEGREES,
49
+ "end_position_long": SEMICIRCLES_TO_DEGREES,
50
+ "nec_lat": SEMICIRCLES_TO_DEGREES,
51
+ "nec_long": SEMICIRCLES_TO_DEGREES,
52
+ "start_position_lat": SEMICIRCLES_TO_DEGREES,
53
+ "start_position_long": SEMICIRCLES_TO_DEGREES,
54
+ "swc_lat": SEMICIRCLES_TO_DEGREES,
55
+ "swc_long": SEMICIRCLES_TO_DEGREES,
56
+ "avg_stroke_distance": M_TO_KM,
57
+ "total_distance": M_TO_KM,
58
+ "avg_ball_speed": MPS_TO_KMH,
59
+ "avg_speed": MPS_TO_KMH,
60
+ "enhanced_avg_speed": MPS_TO_KMH,
61
+ "enhanced_max_speed": MPS_TO_KMH,
62
+ "max_ball_speed": MPS_TO_KMH,
63
+ "max_speed": MPS_TO_KMH,
64
+ }
65
+
66
+
67
+ def _scale_value(value: Any, factor: float) -> Any:
68
+ """Scales one value by ``factor``, recursing into tuples elementwise.
69
+
70
+ Missing values pass through unchanged. Raises ``TypeError`` for a value that can't
71
+ be scaled (e.g. a string).
72
+ """
73
+ if value is None or (isinstance(value, float) and math.isnan(value)):
74
+ return value
75
+ if isinstance(value, tuple):
76
+ return tuple(_scale_value(v, factor) for v in value)
77
+ return value * factor
78
+
79
+
80
+ def _scale_column_elementwise(values: pd.Series, factor: float) -> pd.Series:
81
+ """Scales ``values`` elementwise via ``_scale_value``, all-or-nothing.
82
+
83
+ If any value in the column can't be scaled, the column is returned unchanged.
84
+ """
85
+ try:
86
+ return values.map(lambda v: _scale_value(v, factor))
87
+ except TypeError:
88
+ return values
89
+
90
+
91
+ def convert_units(df: pd.DataFrame, table: Mapping[str, float]) -> pd.DataFrame:
92
+ """Scales each of ``df``'s columns named in ``table`` by its factor.
93
+
94
+ Columns absent from ``table`` are left untouched, as are columns whose values don't
95
+ all coerce to numeric or scale cleanly. A column that decodes to tuples (an
96
+ array-typed FIT field) is scaled elementwise.
97
+ """
98
+ df = df.copy()
99
+
100
+ for column, factor in table.items():
101
+ if column not in df.columns:
102
+ continue
103
+
104
+ values = df[column]
105
+ if not pd.api.types.is_numeric_dtype(values):
106
+ values = coerce_numeric_all_or_nothing(values)
107
+ if pd.api.types.is_numeric_dtype(values):
108
+ df[column] = values * factor
109
+ else:
110
+ df[column] = _scale_column_elementwise(values, factor)
111
+
112
+ return df
113
+
114
+
115
+ def convert_units_mapping(row: Mapping[str, Any], table: Mapping[str, float]) -> dict[str, Any]:
116
+ """Scales each of ``row``'s values named in ``table`` by its factor.
117
+
118
+ The mapping counterpart of ``convert_units``, for messages consumed as plain dicts
119
+ rather than as DataFrames. Non-numeric and missing values are left as they are.
120
+ """
121
+ converted = dict(row)
122
+
123
+ for field, factor in table.items():
124
+ value = converted.get(field)
125
+ if isinstance(value, (int, float)):
126
+ converted[field] = value * factor
127
+
128
+ return converted
@@ -0,0 +1,79 @@
1
+ """Defines the shape of ``parse()``'s return values, shared across FIT/TCX/GPX."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pandas as pd
6
+
7
+ DEFAULT_RECORD_COLUMNS: tuple[str, ...] = (
8
+ "latitude",
9
+ "longitude",
10
+ "altitude",
11
+ "distance",
12
+ "speed",
13
+ "cadence",
14
+ "heart_rate",
15
+ "power",
16
+ "left_balance",
17
+ "right_balance",
18
+ "accumulated_power",
19
+ "temperature",
20
+ "water_temperature",
21
+ "depth",
22
+ "course",
23
+ "bearing",
24
+ )
25
+
26
+ DEFAULT_LAP_COLUMNS: tuple[str, ...] = (
27
+ "start_time",
28
+ "total_elapsed_time",
29
+ "total_timer_time",
30
+ "start_position_lat",
31
+ "start_position_long",
32
+ "end_position_lat",
33
+ "end_position_long",
34
+ "total_distance",
35
+ "total_ascent",
36
+ "total_descent",
37
+ "avg_vam",
38
+ "avg_speed",
39
+ "max_speed",
40
+ "avg_cadence",
41
+ "max_cadence",
42
+ "total_strokes",
43
+ "steps",
44
+ "avg_heart_rate",
45
+ "max_heart_rate",
46
+ "time_in_hr_zone",
47
+ "avg_power",
48
+ "max_power",
49
+ "normalized_power",
50
+ "left_balance",
51
+ "right_balance",
52
+ "time_in_power_zone",
53
+ "total_work",
54
+ "avg_temperature",
55
+ "max_temperature",
56
+ "total_calories",
57
+ "total_fat_calories",
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Activity:
63
+ """File-level summary, present across FIT/TCX/GPX.
64
+
65
+ Fields are ``None`` when the source format/file doesn't record them. Values come
66
+ from the file's own summary fields, never computed/derived from records or laps.
67
+ """
68
+
69
+ sport: str | None = None
70
+ start_time: pd.Timestamp | None = None
71
+ total_elapsed_time: float | None = None # seconds
72
+ total_distance: float | None = None # km
73
+ total_calories: float | None = None
74
+ avg_heart_rate: float | None = None
75
+ max_heart_rate: float | None = None
76
+ avg_speed: float | None = None # kph
77
+ max_speed: float | None = None # kph
78
+ creator: str | None = None # device/app that recorded or wrote the file
79
+ notes: str | None = None