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.
- {activity_parser-0.9.0 → activity_parser-0.11.0}/.pre-commit-config.yaml +1 -1
- activity_parser-0.11.0/PKG-INFO +200 -0
- activity_parser-0.11.0/README.md +177 -0
- {activity_parser-0.9.0 → activity_parser-0.11.0}/pyproject.toml +2 -2
- {activity_parser-0.9.0 → activity_parser-0.11.0}/src/activity_parser/__init__.py +8 -1
- activity_parser-0.11.0/src/activity_parser/exceptions.py +13 -0
- activity_parser-0.11.0/src/activity_parser/fit_fields.py +128 -0
- activity_parser-0.11.0/src/activity_parser/output.py +79 -0
- activity_parser-0.11.0/src/activity_parser/parse_activity.py +146 -0
- activity_parser-0.11.0/src/activity_parser/parse_fit_file.py +342 -0
- activity_parser-0.11.0/src/activity_parser/parse_tcx_gpx.py +259 -0
- activity_parser-0.11.0/src/activity_parser/postprocess.py +50 -0
- activity_parser-0.11.0/src/activity_parser/xml_fields.py +219 -0
- activity_parser-0.11.0/tests/files/README.md +16 -0
- activity_parser-0.11.0/tests/files/fit/DeveloperData.fit +0 -0
- activity_parser-0.11.0/tests/files/fit/garmin-fenix-5-run.fit +0 -0
- activity_parser-0.11.0/tests/files/gpx/gpx10.gpx +30 -0
- activity_parser-0.11.0/tests/files/gpx/gpxdata.gpx +32 -0
- activity_parser-0.11.0/tests/files/gpx/gpxtpx_v1.gpx +38 -0
- activity_parser-0.11.0/tests/files/gpx/metadata.gpx +18 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/mixed_offset.gpx +11 -4
- activity_parser-0.11.0/tests/files/gpx/multi_track.gpx +21 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/no_time.gpx +14 -5
- activity_parser-0.11.0/tests/files/gpx/route_no_time.gpx +18 -0
- activity_parser-0.11.0/tests/files/gpx/unknown_extension.gpx +25 -0
- activity_parser-0.11.0/tests/files/tcx/activity_metadata.tcx +19 -0
- activity_parser-0.11.0/tests/files/tcx/cadence_collision.tcx +33 -0
- activity_parser-0.11.0/tests/files/tcx/duplicate_cadence.tcx +22 -0
- activity_parser-0.11.0/tests/files/tcx/laps_only.tcx +14 -0
- activity_parser-0.11.0/tests/files/tcx/lx_namespace_reset.tcx +26 -0
- activity_parser-0.11.0/tests/files/tcx/mixed_content.tcx +20 -0
- activity_parser-0.11.0/tests/files/tcx/multi_activity.tcx +32 -0
- activity_parser-0.11.0/tests/files/tcx/no_activity.tcx +9 -0
- activity_parser-0.11.0/tests/files/tcx/running.tcx +62 -0
- activity_parser-0.11.0/tests/files/tcx/unknown_collision.tcx +26 -0
- activity_parser-0.11.0/tests/files/tcx/vendor_type_attribute.tcx +22 -0
- activity_parser-0.11.0/tests/synthetic_fit.py +229 -0
- activity_parser-0.11.0/tests/test_fit_fields.py +152 -0
- activity_parser-0.11.0/tests/test_parse_activity.py +108 -0
- activity_parser-0.11.0/tests/test_parse_fit.py +673 -0
- activity_parser-0.11.0/tests/test_parse_tcx_gpx.py +414 -0
- activity_parser-0.11.0/tests/test_postprocess.py +50 -0
- activity_parser-0.11.0/tests/test_readme.py +44 -0
- activity_parser-0.11.0/tests/test_xml_fields.py +59 -0
- activity_parser-0.9.0/PKG-INFO +0 -97
- activity_parser-0.9.0/README.md +0 -74
- activity_parser-0.9.0/src/activity_parser/parse_activity.py +0 -281
- activity_parser-0.9.0/src/activity_parser/parse_fit_file.py +0 -105
- activity_parser-0.9.0/src/activity_parser/parse_tcx_gpx.py +0 -201
- activity_parser-0.9.0/tests/files/README.md +0 -10
- activity_parser-0.9.0/tests/test_parse_activity.py +0 -60
- activity_parser-0.9.0/tests/test_parse_fit.py +0 -99
- activity_parser-0.9.0/tests/test_parse_tcx_gpx.py +0 -212
- {activity_parser-0.9.0 → activity_parser-0.11.0}/.github/workflows/ci.yml +0 -0
- {activity_parser-0.9.0 → activity_parser-0.11.0}/.github/workflows/release.yml +0 -0
- {activity_parser-0.9.0 → activity_parser-0.11.0}/.gitignore +0 -0
- {activity_parser-0.9.0 → activity_parser-0.11.0}/LICENSE +0 -0
- {activity_parser-0.9.0 → activity_parser-0.11.0}/src/activity_parser/py.typed +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/fit}/garmin-edge-820-bike.fit +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/fit}/garmin-fenix-5-bike.fit +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/bad_speed.gpx +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/multi_segment.gpx +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/gpx}/sample.gpx +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/mixed_offset.tcx +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/no_position.tcx +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/no_time.tcx +0 -0
- {activity_parser-0.9.0/tests/files → activity_parser-0.11.0/tests/files/tcx}/sample.tcx +0 -0
|
@@ -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
|
+
[](https://github.com/tabishm52/activity_parser/actions/workflows/ci.yml)
|
|
27
|
+
[](https://pypi.org/project/activity-parser/)
|
|
28
|
+
[](https://pypi.org/project/activity-parser/)
|
|
29
|
+
[](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
|
+
[](https://github.com/tabishm52/activity_parser/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/activity-parser/)
|
|
5
|
+
[](https://pypi.org/project/activity-parser/)
|
|
6
|
+
[](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.
|
|
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
|
-
"
|
|
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
|