Skip to content

Commit d7bfba8

Browse files
authored
Merge branch 'main' into elevation_nan
2 parents ac92044 + 9d6ade6 commit d7bfba8

File tree

4 files changed

+156
-25
lines changed

4 files changed

+156
-25
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
__pycache__
2+
dist

README.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ I decided to create this package after spending a few hours searching for a simp
1919

2020
#### Relevance to Strava
2121
- Pre-GPDR, you could bulk export all your Strava activities as GPX files.
22-
- Post-GDPR, you can export an archive of your account. Whilst this includes much more data, activity GPS files are now downloaded in their original file format (eg. GPX or FIT format, some gzipped, some not) and named like 2500155647.gpx, 2500155647.gpx.gz, 2500155647.fit, and 2500155647.fit.gz.
22+
- Post-GDPR, you can export an archive of your account. Whilst this includes much more data, activity GPS files are now downloaded in their original file format (eg. GPX or FIT format, some gzipped, some not) and named like 2500155647.gpx, 2500155647.gpx.gz, 2500155647.fit, and 2500155647.fit.gz.
2323
- [How to bulk export you Strava Data](https://support.strava.com/hc/en-us/articles/216918437-Exporting-your-Data-and-Bulk-Export#Bulk)
2424

2525
# Overview
26-
The fit2gpx module provides two converter classes:
26+
The fit2gpx module provides two converter classes:
2727
- Converter: used to convert a single or multiple FIT files to pandas dataframes or GPX files
2828
- StravaConverter: used to fix all the Strava Bulk Export problems in three steps:
2929
1. Unzip GPX and FIT files
@@ -45,7 +45,7 @@ df_lap, df_point = conv.fit_to_dataframes(fname='3323369944.fit')
4545
- df_points: information per track point: longitude, latitude, altitude, timestamp, heart rate, cadence, speed, power, temperature
4646
- Note the 'enhanced_speed' and 'enhanced_altitude' are also extracted. Where overlap exists with their default counterparts, values are identical. However, the default or enhanced speed/altitude fields may be empty depending on the device used to record ([detailed information](https://pkg.go.dev/github.com/tormoder/fit#RecordMsg)).
4747

48-
48+
4949
# Use Case 2: FIT to GPX
5050
Import module and create converter object
5151
```python
@@ -70,8 +70,8 @@ from fit2gpx import StravaConverter
7070

7171
DIR_STRAVA = 'C:/Users/dorian-saba/Documents/Strava/'
7272

73-
# Step 1: Create StravaConverter object
74-
# - Note: the dir_in must be the path to the central unzipped Strava bulk export folder
73+
# Step 1: Create StravaConverter object
74+
# - Note: the dir_in must be the path to the central unzipped Strava bulk export folder
7575
# - Note: You can specify the dir_out if you wish. By default it is set to 'activities_gpx', which will be created in main Strava folder specified.
7676

7777
strava_conv = StravaConverter(
@@ -92,6 +92,34 @@ strava_conv.strava_fit_to_gpx()
9292
#### pandas
9393
[pandas](https://github.com/pandas-dev/pandas) is a Python package that provides fast, flexible, and expressive data structures designed to make working with "relational" or "labeled" data both easy and intuitive.
9494
#### gpxpy
95-
[gpxpy](https://github.com/tkrajina/gpxpy) is a simple Python library for parsing and manipulating GPX files. It can parse and generate GPX 1.0 and 1.1 files. The generated file will always be a valid XML document, but it may not be (strictly speaking) a valid GPX document.
95+
[gpxpy](https://github.com/tkrajina/gpxpy) is a simple Python library for parsing and manipulating GPX files. It can parse and generate GPX 1.0 and 1.1 files. The generated file will always be a valid XML document, but it may not be (strictly speaking) a valid GPX document.
9696
#### fitdecode
9797
[fitdecode](https://github.com/polyvertex/fitdecode) is a rewrite of the [fitparse](https://github.com/dtcooper/python-fitparse) module allowing to parse ANT/GARMIN FIT files.
98+
99+
# Command line interface
100+
101+
You can install this package using pip:
102+
103+
```shell
104+
pip install --user --upgrade .
105+
```
106+
107+
And then you can run the `fit2gpx` command to convert a FIT file to GPX:
108+
109+
```shell
110+
fit2gpx 3323369944.fit 3323369944.gpx
111+
```
112+
113+
You can also read the FIT file from standard input and/or write the GPX file to
114+
standard output:
115+
116+
```shell
117+
fit2gpx - 3323369944.gpx < 3323369944.fit
118+
fit2gpx 3323369944.fit - > 3323369944.gpx
119+
```
120+
121+
To see the help, run:
122+
123+
```shell
124+
fit2gpx -h
125+
```

pyproject.toml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
[tool.poetry]
2+
name = "fit2gpx"
3+
version = "0.0.7"
4+
description = "Package to convert .FIT files to .GPX files, including tools for .FIT files downloaded from Strava"
5+
authors = ["Dorian Sabathier <dorian.sabathier+PyPi@gmail.com>"]
6+
license = "AGPL-3.0-only"
7+
readme = "README.md"
8+
homepage = "https://github.com/dodo-saba/fit2gpx"
9+
repository = "https://github.com/dodo-saba/fit2gpx"
10+
keywords = ["convert", ".fit", "fit", ".gpx", "gpx", "strava"]
11+
classifiers = [
12+
"Development Status :: 5 - Production/Stable",
13+
"Intended Audience :: Developers",
14+
"Intended Audience :: Other Audience",
15+
"Topic :: Scientific/Engineering :: GIS",
16+
"License :: OSI Approved :: GNU Affero General Public License v3",
17+
"Operating System :: OS Independent",
18+
"Programming Language :: Python :: 3",
19+
"Programming Language :: Python :: 3.6",
20+
"Programming Language :: Python :: 3.7",
21+
"Programming Language :: Python :: 3.8",
22+
"Programming Language :: Python :: 3.9",
23+
"Programming Language :: Python :: 3.10"
24+
]
25+
packages = [
26+
{ include = "fit2gpx.py", from = "src" }
27+
]
28+
29+
[tool.poetry.dependencies]
30+
python = "^3.6"
31+
pandas = "^1.5.3"
32+
fitdecode = "^0.10.0"
33+
gpxpy = "^1.5.0"
34+
35+
[tool.poetry.scripts]
36+
fit2gpx = "fit2gpx:cli"
37+
38+
[build-system]
39+
requires = ["poetry-core"]
40+
build-backend = "poetry.core.masonry.api"

src/fit2gpx.py

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
"""Classes to convert FIT files to GPX, including tools to process Strava Bulk Export
22
"""
3-
import os
3+
import argparse
44
import gzip
5+
import os
56
import shutil
67
from datetime import datetime, timedelta
7-
from typing import Dict, Union, Optional, Tuple
8+
from typing import Dict, Optional, Tuple, Union
9+
810
import pandas as pd
9-
import gpxpy.gpx
10-
import fitdecode
1111
from math import isnan
12+
import lxml.etree as mod_etree
13+
14+
import fitdecode
15+
import gpxpy.gpx
1216

1317

1418
# MAIN CONVERTER CLASS
@@ -99,10 +103,11 @@ def fit_to_dataframes(self, fname: str) -> Tuple[pd.DataFrame, pd.DataFrame]:
99103
Returns:
100104
dfs (tuple): df containing data about the laps , df containing data about the individual points.
101105
"""
102-
# Check that this is a .FIT file
103-
input_extension = os.path.splitext(fname)[1]
104-
if input_extension.lower() != '.fit':
105-
raise fitdecode.exceptions.FitHeaderError("Input file must be a .FIT file.")
106+
if isinstance(fname, str) or hasattr(fname, '__fspath__'):
107+
# Check that this is a .FIT file
108+
input_extension = os.path.splitext(fname)[1]
109+
if input_extension.lower() != '.fit':
110+
raise fitdecode.exceptions.FitHeaderError("Input file must be a .FIT file.")
106111

107112
data_points = []
108113
data_laps = []
@@ -134,7 +139,7 @@ def fit_to_dataframes(self, fname: str) -> Tuple[pd.DataFrame, pd.DataFrame]:
134139

135140
# Method adapted from: https://github.com/nidhaloff/gpx-converter/blob/master/gpx_converter/base.py
136141
def dataframe_to_gpx(self, df_points, col_lat='latitude', col_long='longitude', col_time=None, col_alt=None,
137-
gpx_name=None, gpx_desc=None, gpx_link=None, gpx_type=None):
142+
col_hr=None, col_cad=None, gpx_name=None, gpx_desc=None, gpx_link=None, gpx_type=None):
138143
"""
139144
Convert a pandas dataframe to gpx
140145
Parameters:
@@ -143,6 +148,8 @@ def dataframe_to_gpx(self, df_points, col_lat='latitude', col_long='longitude',
143148
col_time (str): name of the time column
144149
col_long (str): name of the longitudes column
145150
col_lat (str): name of the latitudes column
151+
col_hr (str): name of the heart rate column
152+
col_cad (str): name of the cadence column
146153
gpx_name (str): name for the gpx track (note is not the same as the file name)
147154
gpx_desc (str): description for the gpx track
148155
gpx_type : activity type for the gpx track (can be str, or int)
@@ -168,6 +175,10 @@ def dataframe_to_gpx(self, df_points, col_lat='latitude', col_long='longitude',
168175
gpx_segment = gpxpy.gpx.GPXTrackSegment()
169176
gpx_track.segments.append(gpx_segment)
170177

178+
# add extension to be able to add heartrate and cadence
179+
if col_hr or col_cad:
180+
gpx.nsmap = {'gpxtpx': 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1'}
181+
171182
# Step 2: Assign GPX track metadata
172183
gpx.tracks[0].name = gpx_name
173184
gpx.tracks[0].type = gpx_type
@@ -192,6 +203,19 @@ def dataframe_to_gpx(self, df_points, col_lat='latitude', col_long='longitude',
192203
elevation=df_points.loc[idx, col_alt] if col_alt else None,
193204
)
194205

206+
# add GPX extensions for heartrate and cadence
207+
if col_hr or col_cad:
208+
namespace = '{gpxtpx}'
209+
root = mod_etree.Element(f'{namespace}TrackPointExtension')
210+
if col_hr:
211+
sub_hr = mod_etree.SubElement(root, f'{namespace}hr')
212+
sub_hr.text = str(df_points.loc[idx, col_hr]) if col_hr else '0'
213+
214+
if col_cad:
215+
sub_cad = mod_etree.SubElement(root, f'{namespace}cad')
216+
sub_cad.text = str(df_points.loc[idx, col_cad]) if col_cad else '0'
217+
track_point.extensions.append(root)
218+
195219
# Append GPX_TrackPoint to segment:
196220
gpx_segment.points.append(track_point)
197221

@@ -203,14 +227,16 @@ def fit_to_gpx(self, f_in, f_out):
203227
f_in (str): file path to FIT activity
204228
f_out (str): file path to save the converted FIT file
205229
"""
206-
# Step 0: Validate inputs
207-
input_extension = os.path.splitext(f_in)[1]
208-
if input_extension != '.fit':
209-
raise Exception("Input file must be a .FIT file.")
230+
if isinstance(f_in, str) or hasattr(f_in, '__fspath__'):
231+
# Step 0: Validate inputs
232+
input_extension = os.path.splitext(f_in)[1]
233+
if input_extension != '.fit':
234+
raise Exception("Input file must be a .FIT file.")
210235

211-
output_extension = os.path.splitext(f_out)[1]
212-
if output_extension != ".gpx":
213-
raise TypeError(f"Output file must be a .gpx file.")
236+
if isinstance(f_out, str) or hasattr(f_out, '__fspath__'):
237+
output_extension = os.path.splitext(f_out)[1]
238+
if output_extension != ".gpx":
239+
raise TypeError(f"Output file must be a .gpx file.")
214240

215241
# Step 1: Convert FIT to pd.DataFrame
216242
df_laps, df_points = self.fit_to_dataframes(f_in)
@@ -219,7 +245,7 @@ def fit_to_gpx(self, f_in, f_out):
219245
enhanced_fields = ['altitude', 'speed']
220246
for field in enhanced_fields:
221247
if df_points[field].count() == 0 and df_points[f'enhanced_{field}'].count() > 0:
222-
df_points[field].fillna(df_points[f'enhanced_{field}'], inplace=True)
248+
df_points[field] = df_points[field].fillna(df_points[f'enhanced_{field}'])
223249

224250
# Step 3: Convert pd.DataFrame to GPX
225251
gpx = self.dataframe_to_gpx(
@@ -228,11 +254,17 @@ def fit_to_gpx(self, f_in, f_out):
228254
col_long='longitude',
229255
col_time='timestamp',
230256
col_alt='altitude',
257+
col_hr='heart_rate',
258+
col_cad='cadence',
231259
)
232260

233261
# Step 3: Save file
234-
with open(f_out, 'w') as f:
235-
f.write(gpx.to_xml())
262+
xml = gpx.to_xml()
263+
if hasattr(f_out, 'write'):
264+
f_out.write(xml)
265+
else:
266+
with open(f_out, 'w') as f:
267+
f.write(xml)
236268

237269
return gpx
238270

@@ -387,6 +419,8 @@ def strava_fit_to_gpx(self):
387419
col_long='longitude',
388420
col_time='timestamp',
389421
col_alt='altitude',
422+
col_hr='heart_rate',
423+
col_cad='cadence',
390424
**strava_args
391425
)
392426

@@ -432,6 +466,10 @@ def add_metadata_to_gpx(self):
432466
f_gpx = open(self._dir_activities + gpx_path, 'r', encoding='utf-8')
433467
gpx = gpxpy.parse(f_gpx)
434468

469+
# Skip any file that does not have tracks (i.e. no geospatial data, e.g. workouts or pool swims)
470+
if len(gpx.tracks) == 0:
471+
continue
472+
435473
# -- assign GPX track metadata
436474
gpx.tracks[0].name = act_name
437475
gpx.tracks[0].type = md['Activity Type']
@@ -446,3 +484,26 @@ def add_metadata_to_gpx(self):
446484
# Step 2.4: Print
447485
if self.status_msg:
448486
print(f'{len(gpx_files)} .gpx files have had Strava metadata added.')
487+
488+
489+
def cli():
490+
parser = argparse.ArgumentParser(
491+
prog='fit2gpx',
492+
description="Convert a .FIT file to .GPX."
493+
)
494+
parser.add_argument(
495+
'infile',
496+
type=argparse.FileType('rb'),
497+
help='path to the input .FIT file; '
498+
"use '-' to read the file from standard input"
499+
)
500+
parser.add_argument(
501+
'outfile',
502+
type=argparse.FileType('wt'),
503+
help='path to the output .GPX file; '
504+
"use '-' to write the file to standard output"
505+
)
506+
args = parser.parse_args()
507+
508+
conv = Converter()
509+
conv.fit_to_gpx(f_in=args.infile, f_out=args.outfile)

0 commit comments

Comments
 (0)