source: OpenWorkouts-current/ow/fit.py @ 53bb3e5

currentfeature/docs
Last change on this file since 53bb3e5 was 53bb3e5, checked in by borja <borja@…>, 5 years ago

(#13) - fit files parsing + (#26) - generate .gpx from .fit

  • Added fitparse as a new dependency IMPORTANT: please install it in your existing envs:

pip install python-fitparse

  • Added new attribute to workouts to store attached fit files as Blob objects. IMPORTANT: please update any workouts you have in your db, adding the fit_file attribute to them (None by default)
  • Added new module with the code needed to interact with .fit files (parse, gather data, transform to gpx)
  • Added code to "load" a workout from a fit file
  • Added tools and helpers to transform values (meters->kilometers, meters-per-second->kms-per-hour, semicircles-to-degrees, etc)
  • Refactored some imports
  • Property mode set to 100644
File size: 8.5 KB
Line 
1from fitparse import FitFile
2import gpxpy
3import gpxpy.gpx
4
5try:
6    import lxml.etree as mod_etree  # Load LXML or fallback to cET or ET
7except:
8    try:
9        import xml.etree.cElementTree as mod_etree
10    except:
11        import xml.etree.ElementTree as mod_etree
12
13from ow.utilities import semicircles_to_degrees
14
15
16class Fit(object):
17    """
18    A fit object, that can be used to load, parse, convert, etc ANT fit
19    files
20    """
21    def __init__(self, path):
22        self.path = path
23        # FitFile object containing all data after opening the file with
24        # python-fitparse
25        self.obj = FitFile(path)
26        # data is a temporary place to store info we got from the fit file,
27        # so we don't have to loop over data in self.obj multiple times
28        self.data = {}
29
30    def load(self):
31        """
32        Load contents from the fit file
33        """
34        # get some basic info from the fit file
35        #
36        # IMPORTANT: only single-session fit files are supported right now
37        sessions = list(self.obj.get_messages('session'))
38        session = sessions[0]
39        values = session.get_values()
40        self.data['sport'] = values.get('sport', None)
41
42        # in some garmin devices (520), you can have named profiles, which
43        # can be used for different bikes or for different types of workouts
44        # (training/races/etc)
45        self.data['profile'] = values.get('unknown_110', None)
46
47        # naive datetime object
48        self.data['start'] = values.get('start_time', None)
49
50        # seconds
51        self.data['duration'] = values.get('total_timer_time', None)
52        self.data['elapsed'] = values.get('total_elapsed_time', None)
53
54        # meters
55        self.data['distance'] = values.get('total_distance', None)
56
57        # meters
58        self.data['uphill'] = values.get('total_ascent', None)
59        self.data['downhill'] = values.get('total_descent', None)
60
61        self.data['calories'] = values.get('total_calories', None)
62
63        # store here a list with all hr values
64        self.data['hr'] = []
65        self.data['min_hr'] = None
66        self.data['max_hr'] = values.get('max_heart_rate', None)
67        self.data['avg_hr'] = values.get('avg_heart_rate', None)
68
69        # store here a list with all cad values
70        self.data['cad'] = []
71        self.data['min_cad'] = None
72        self.data['max_cad'] = values.get('max_cadence', None)
73        self.data['avg_cad'] = values.get('avg_cadence', None)
74
75        self.data['max_speed'] = values.get('enhanced_max_speed', None)
76        if self.data['max_speed'] is None:
77            self.data['max_speed'] = values.get('max_speed', None)
78            if self.data['max_speed'] is not None:
79                self.data['max_speed'] = self.data['max_speed'] / 1000
80
81        self.data['avg_speed'] = values.get('enhanced_avg_speed', None)
82        if self.data['avg_speed'] is None:
83            self.data['avg_speed'] = values.get('avg_speed', None)
84            if self.data['avg_speed'] is not None:
85                self.data['avg_speed'] = self.data['avg_speed'] / 1000
86
87        # no temp values yet
88        # store here a list with all temp values
89        self.data['atemp'] = []
90        self.data['min_atemp'] = None
91        self.data['max_atemp'] = 0
92        self.data['avg_atemp'] = 0
93
94    @property
95    def name(self):
96        """
97        Build a name based on some info from the fit file
98        """
99        if self.data['profile']:
100            return self.data['profile'] + ' ' + self.data['sport']
101        return self.data['sport']
102
103    def _calculate_avg_atemp(self):
104        temps = [t[0] for t in self.data['atemp']]
105        if temps:
106            self.data['avg_atemp'] = int(round(sum(temps)/len(temps)))
107        return self.data['avg_atemp']
108
109    @property
110    def gpx(self):
111        """
112        Return valid xml-formatted gpx contents from the loaded fit file
113        """
114        # Create a gpx object, adding some basic metadata to it.
115        #
116        # The schema namespaces info is very important, in order to support
117        # all the data we will extract from the fit file.
118        gpx = gpxpy.gpx.GPX()
119        gpx.creator = 'OpenWorkouts'
120        gpx.schema_locations = [
121            'http://www.topografix.com/GPX/1/1',
122            'http://www.topografix.com/GPX/1/1/gpx.xsd',
123            'http://www.garmin.com/xmlschemas/GpxExtensions/v3',
124            'http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd',
125            'http://www.garmin.com/xmlschemas/TrackPointExtension/v1',
126            'http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd'
127        ]
128        garmin_schemas = 'http://www.garmin.com/xmlschemas'
129        gpx.nsmap['gpxtpx'] = garmin_schemas + '/TrackPointExtension/v1'
130        gpx.nsmap['gpxx'] = garmin_schemas + '/GpxExtensions/v3'
131
132        # Create first track in our GPX:
133        gpx_track = gpxpy.gpx.GPXTrack()
134        gpx_track.name = self.name
135        gpx.tracks.append(gpx_track)
136
137        # Create first segment in our GPX track:
138        gpx_segment = gpxpy.gpx.GPXTrackSegment()
139        gpx_track.segments.append(gpx_segment)
140
141        # namespace for the additional gpx extensions
142        # (temperature, cadence, heart rate)
143        namespace = '{gpxtpx}'
144        # nsmap = {'ext': namespace[1:-1]}
145
146        # loop over the 'record' messages in the fit file, which are the actual
147        # data "recorded" by the device
148        for message in self.obj.get_messages('record'):
149            values = message.get_values()
150
151            # Create an entry where we add the "gpx extensions" data, that will
152            # be added to the track point later.
153            extensions_root = mod_etree.Element(
154                namespace + 'TrackPointExtension')
155
156            atemp = mod_etree.Element(namespace + 'atemp')
157            atemp_value = values.get('temperature', 0)
158            atemp.text = str(atemp_value)
159            atemp.tail = ''
160            extensions_root.append(atemp)
161            self.data['atemp'].append((atemp_value, values['timestamp']))
162            if self.data['min_atemp'] is None:
163                self.data['min_atemp'] = atemp_value
164            elif atemp_value < self.data['min_atemp']:
165                self.data['min_atemp'] = atemp_value
166            if atemp_value > self.data['max_atemp']:
167                self.data['max_atemp'] = atemp_value
168
169            hr = mod_etree.Element(namespace + 'hr')
170            hr_value = values.get('heart_rate', 0)
171            if hr_value == 0:
172                # don't allow hr values of 0, something may have gone wrong
173                # with the heart rate monitor, so we use the previous value,
174                # if any
175                if self.data['hr']:
176                    hr_value = self.data['hr'][-1][0]
177            hr.text = str(hr_value)
178            hr.tail = ''
179            extensions_root.append(hr)
180            self.data['hr'].append((hr_value, values['timestamp']))
181            # min_hr None means we are on the first value, set it as min, we
182            # don't want 0 values here. If it is not None, check if the current
183            # value is lower than the current min, update accordingly
184            if self.data['min_hr'] is None or hr_value < self.data['min_hr']:
185                self.data['min_hr'] = hr_value
186
187            cad = mod_etree.Element(namespace + 'cad')
188            cad_value = values.get('cadence', 0)
189            cad.text = str(cad_value)
190            cad.tail = ''
191            extensions_root.append(cad)
192            self.data['cad'].append((cad_value, values['timestamp']))
193            if self.data['min_cad'] is None:
194                self.data['min_cad'] = cad_value
195            elif cad_value < self.data['min_cad']:
196                self.data['min_cad'] = cad_value
197
198            # Create a gpx track point, that holds the "recorded" geo and speed
199            # data, as well as the "gpx extensions" object
200            if values.get('position_lat') and values.get('position_long'):
201                track_point = gpxpy.gpx.GPXTrackPoint(
202                    latitude=semicircles_to_degrees(values['position_lat']),
203                    longitude=semicircles_to_degrees(values['position_long']),
204                    elevation=values['enhanced_altitude'],
205                    speed=values['enhanced_speed'],
206                    time=values['timestamp']
207                )
208
209            track_point.extensions.append(extensions_root)
210
211            gpx_segment.points.append(track_point)
212
213        # if the fit file has temperature data, calculate the avg
214        if self.data['atemp']:
215            self._calculate_avg_atemp()
216
217        # return a string containing the xml representation of the gpx object
218        return gpx.to_xml()
Note: See TracBrowser for help on using the repository browser.