source: OpenWorkouts-current/ow/models/workout.py @ c555386

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

(#26) Several bugfixes when loading data from fit, then generating gpx code

from it:

  • Ensure track points are added to a generated gpx (from a fit file) if they exist.
  • Ensure ascent (uphill), descent (downhill) and distance data is added to the Workout object if there is any in the fit file (fixes loading fit files for indoor training without gps data on them)
  • Load the "sport" property from the fit file when loading data from fit into a Workout object
  • Property mode set to 100644
File size: 13.8 KB
Line 
1
2from datetime import datetime, timedelta, timezone
3from decimal import Decimal
4
5import pytz
6import gpxpy
7from repoze.folder import Folder
8from pyramid.security import Allow, Everyone
9
10from ow.utilities import (
11    GPXMinidomParser,
12    copy_blob,
13    create_blob,
14)
15
16from ow.fit import Fit
17
18
19class Workout(Folder):
20
21    __parent__ = __name__ = None
22
23    def __acl__(self):
24        """
25        If the workout is owned by a given user, only that user have access to
26        it (for now). If not, everybody can view it, only admins can edit it.
27        """
28        # Default permissions
29        permissions = [
30            (Allow, Everyone, 'view'),
31            (Allow, 'group:admins', 'edit')
32        ]
33
34        uid = getattr(self.__parent__, 'uid', None)
35        if uid is not None:
36            # Change permissions in case this workout has an owner
37            permissions = [
38                (Allow, str(uid), 'view'),
39                (Allow, str(uid), 'edit'),
40            ]
41        return permissions
42
43    def __init__(self, **kw):
44        super(Workout, self).__init__()
45        # we do store datetime objects with UTC timezone
46        self.start = kw.get('start', datetime.now(timezone.utc))
47        self.sport = kw.get('sport', 'unknown')  # string
48        self.title = kw.get('title', '')  # unicode string
49        self.notes = kw.get('notes', '')  # unicode string
50        self.duration = kw.get('duration', None)  # a timedelta object
51        self.distance = kw.get('distance', None)  # kilometers, Decimal
52        self.hr_min = kw.get('hr_min', None)  # bpm, Decimal
53        self.hr_max = kw.get('hr_max', None)  # bpm, Decimal
54        self.hr_avg = kw.get('hr_avg', None)  # bpm, Decimal
55        self.uphill = kw.get('uphill', None)
56        self.downhill = kw.get('downhill', None)
57        self.cad_min = kw.get('cad_min', None)
58        self.cad_max = kw.get('cad_max', None)
59        self.cad_avg = kw.get('cad_avg', None)
60        self.atemp_min = kw.get('atemp_min', None)
61        self.atemp_max = kw.get('atemp_max', None)
62        self.atemp_avg = kw.get('atemp_avg', None)
63        self.tracking_file = kw.get('tracking_file', None)  # Blob
64        self.tracking_filetype = ''  # unicode string
65        # attr to store ANT fit files. For now this file is used to
66        # generate a gpx-encoded tracking file we then use through
67        # the whole app
68        self.fit_file = kw.get('fit_file', None)  # Blob
69
70    @property
71    def workout_id(self):
72        return self.__name__
73
74    @property
75    def owner(self):
76        return self.__parent__
77
78    @property
79    def end(self):
80        if not self.duration:
81            return None
82        return self.start + self.duration
83
84    @property
85    def start_date(self):
86        return self.start.strftime('%d/%m/%Y')
87
88    @property
89    def start_time(self):
90        return self.start.strftime('%H:%M')
91
92    def start_in_timezone(self, timezone):
93        """
94        Return a string representation of the start date and time,
95        localized into the given timezone
96        """
97        _start = self.start.astimezone(pytz.timezone(timezone))
98        return _start.strftime('%d/%m/%Y %H:%M (%Z)')
99
100    def end_in_timezone(self, timezone):
101        """
102        Return a string representation of the end date and time,
103        localized into the given timezone
104        """
105        _end = self.end.astimezone(pytz.timezone(timezone))
106        return _end.strftime('%d/%m/%Y %H:%M (%Z)')
107
108    def split_duration(self):
109        hours, remainder = divmod(int(self.duration.total_seconds()), 3600)
110        minutes, seconds = divmod(remainder, 60)
111        return hours, minutes, seconds
112
113    @property
114    def duration_hours(self):
115        return str(self.split_duration()[0]).zfill(2)
116
117    @property
118    def duration_minutes(self):
119        return str(self.split_duration()[1]).zfill(2)
120
121    @property
122    def duration_seconds(self):
123        return str(self.split_duration()[2]).zfill(2)
124
125    @property
126    def _duration(self):
127        return ':'.join(
128            [self.duration_hours, self.duration_minutes, self.duration_seconds]
129        )
130
131    @property
132    def rounded_distance(self):
133        """
134        Return rounded value for distance, '-' if the workout has no distance
135        data (weight lifting, martial arts, table tennis, etc)
136        """
137        if self.distance:
138            return round(self.distance, 1)
139        return '-'
140
141    @property
142    def has_hr(self):
143        """
144        True if this workout has heart rate data, False otherwise
145        """
146        data = [self.hr_min, self.hr_max, self.hr_avg]
147        return data.count(None) == 0
148
149    @property
150    def hr(self):
151        """
152        Return a dict with rounded values for hr min, max and average,
153        return None if there is no heart rate data for this workout
154        """
155        if self.has_hr:
156            return {'min': round(self.hr_min),
157                    'max': round(self.hr_max),
158                    'avg': round(self.hr_avg)}
159        return None
160
161    @property
162    def has_cad(self):
163        """
164        True if this workout has cadence data, False otherwise
165        """
166        data = [self.cad_min, self.cad_max, self.cad_avg]
167        return data.count(None) == 0
168
169    @property
170    def cad(self):
171        """
172        Return a dict with rounded values for cadence min, max and average
173        return None if there is no cadence data for this workout
174        """
175        if self.has_cad:
176            return {'min': round(self.cad_min),
177                    'max': round(self.cad_max),
178                    'avg': round(self.cad_avg)}
179        return None
180
181    @property
182    def has_atemp(self):
183        """
184        True if this workout has temperature data, False otherwise
185        """
186        data = [self.atemp_min, self.atemp_max, self.atemp_avg]
187        return data.count(None) == 0
188
189    @property
190    def atemp(self):
191        """
192        Return a dict with rounded values for temperature min, max and average
193        return None if there is no temperature data for this workout
194        """
195        if self.has_atemp:
196            return {'min': round(self.atemp_min),
197                    'max': round(self.atemp_max),
198                    'avg': round(self.atemp_avg)}
199        return None
200
201    @property
202    def tracking_file_path(self):
203        """
204        Get the path to the blob file attached as a tracking file.
205
206        First check if the file was not committed to the db yet (new workout
207        not saved yet) and use the path to the temporary file on the fs.
208        If none is found there, go for the real blob file in the blobs
209        directory
210        """
211        path = None
212        if self.tracking_file:
213            path = self.tracking_file._p_blob_uncommitted
214            if path is None:
215                path = self.tracking_file._p_blob_committed
216        return path
217
218    @property
219    def fit_file_path(self):
220        """
221        Get the path to the blob file attached as a fit file.
222
223        First check if the file was not committed to the db yet (new workout
224        not saved yet) and use the path to the temporary file on the fs.
225        If none is found there, go for the real blob file in the blobs
226        directory
227        """
228        path = None
229        if self.fit_file:
230            path = self.fit_file._p_blob_uncommitted
231            if path is None:
232                path = self.fit_file._p_blob_committed
233        return path
234
235    def load_from_file(self):
236        """
237        Check which kind of tracking file we have for this workout, then call
238        the proper method to load info from the tracking file
239        """
240        if self.tracking_filetype == 'gpx':
241            self.load_from_gpx()
242        elif self.tracking_filetype == 'fit':
243            self.load_from_fit()
244
245    def load_from_gpx(self):
246        """
247        Load some information from an attached gpx file. Return True if data
248        had been succesfully loaded, False otherwise
249        """
250        with self.tracking_file.open() as gpx_file:
251            gpx_contents = gpx_file.read()
252            gpx_contents = gpx_contents.decode('utf-8')
253            gpx = gpxpy.parse(gpx_contents)
254            if gpx.tracks:
255                track = gpx.tracks[0]
256                # Start time comes in UTC/GMT/ZULU
257                time_bounds = track.get_time_bounds()
258                self.start = time_bounds.start_time
259                # ensure this datetime start object is timezone-aware
260                self.start = self.start.replace(tzinfo=timezone.utc)
261                # get_duration returns seconds
262                self.duration = timedelta(seconds=track.get_duration())
263                # length_3d returns meters
264                self.distance = Decimal(track.length_3d()) / Decimal(1000.00)
265                ud = track.get_uphill_downhill()
266                self.uphill = Decimal(ud.uphill)
267                self.downhill = Decimal(ud.downhill)
268                # If the user did not provide us with a title, and the gpx has
269                # one, use that
270                if not self.title and track.name:
271                    self.title = track.name
272
273                # Hack to calculate some values from the GPX 1.1 extensions,
274                # using our own parser (gpxpy does not support those yet)
275                tracks = self.parse_gpx()
276                hr = []
277                cad = []
278                atemp = []
279                for t in tracks:
280                    hr += [
281                        d['hr'] for d in tracks[t] if d['hr'] is not None]
282                    cad += [
283                        d['cad'] for d in tracks[t] if d['cad'] is not None]
284                    atemp += [
285                        d['atemp'] for d in tracks[t]
286                        if d['atemp'] is not None]
287
288                if hr:
289                    self.hr_min = Decimal(min(hr))
290                    self.hr_avg = Decimal(sum(hr)) / Decimal(len(hr))
291                    self.hr_max = Decimal(max(hr))
292
293                if cad:
294                    self.cad_min = Decimal(min(cad))
295                    self.cad_avg = Decimal(sum(cad)) / Decimal(len(cad))
296                    self.cad_max = Decimal(max(cad))
297
298                if atemp:
299                    self.atemp_min = Decimal(min(atemp))
300                    self.atemp_avg = Decimal(sum(atemp)) / Decimal(len(atemp))
301                    self.atemp_max = Decimal(max(atemp))
302
303                return True
304
305        return False
306
307    def parse_gpx(self):
308        """
309        Parse the gpx using minidom.
310
311        This method is needed as a workaround to get HR/CAD/TEMP values from
312        gpx 1.1 extensions (gpxpy does not handle them correctly so far)
313        """
314        if not self.has_gpx:
315            # No gpx, nothing to return
316            return {}
317
318        # Get the path to the blob file, first check if the file was not
319        # committed to the db yet (new workout not saved yet) and use the
320        # path to the temporary file on the fs. If none is found there, go
321        # for the final blob file
322        gpx_path = self.tracking_file._p_blob_uncommitted
323        if gpx_path is None:
324            gpx_path = self.tracking_file._p_blob_committed
325
326        # Create a parser, load the gpx and parse the tracks
327        parser = GPXMinidomParser(gpx_path)
328        parser.load_gpx()
329        parser.parse_tracks()
330        return parser.tracks
331
332    def load_from_fit(self):
333        """
334        Try to load data from an ANT-compatible .fit file (if any has been
335        added to this workout).
336
337        "Load data" means:
338
339        1. Copy over the uploaded fit file to self.fit_file, so we can keep
340           that copy around for future use
341
342        2. generate a gpx object from the fit file
343
344        3. save the gpx object as the tracking_file, which then will be used
345           by the current code to display and gather data to be displayed/shown
346           to the user.
347
348        4. Grab some basic info from the fit file and store it in the Workout
349        """
350        # backup the fit file
351        self.fit_file = copy_blob(self.tracking_file)
352
353        # create an instance of our Fit class
354        fit = Fit(self.fit_file_path)
355        fit.load()
356
357        # fit -> gpx and store that as the main tracking file
358        self.tracking_file = create_blob(fit.gpx, 'gpx')
359        self.tracking_filetype = 'gpx'
360
361        # grab the needed data from the fit file, update the workout
362        self.sport = fit.data['sport']
363        self.start = fit.data['start']
364        # ensure this datetime start object is timezone-aware
365        self.start = self.start.replace(tzinfo=timezone.utc)
366        # duration comes in seconds, store a timedelta
367        self.duration = timedelta(seconds=fit.data['duration'])
368        if fit.data['distance']:
369            # distance comes in meters
370            self.distance = Decimal(fit.data['distance']) / Decimal(1000.00)
371        if fit.data['uphill']:
372            self.uphill = Decimal(fit.data['uphill'])
373        if fit.data['downhill']:
374            self.downhill = Decimal(fit.data['downhill'])
375        # If the user did not provide us with a title, build one from the
376        # info in the fit file
377        if not self.title:
378            self.title = fit.name
379
380        if fit.data['avg_hr']:
381            self.hr_avg = Decimal(fit.data['avg_hr'])
382            self.hr_min = Decimal(fit.data['min_hr'])
383            self.hr_max = Decimal(fit.data['max_hr'])
384
385        if fit.data['avg_cad']:
386            self.cad_avg = Decimal(fit.data['avg_cad'])
387            self.cad_min = Decimal(fit.data['min_cad'])
388            self.cad_max = Decimal(fit.data['max_cad'])
389
390        if fit.data['avg_atemp']:
391            self.atemp_avg = Decimal(fit.data['avg_atemp'])
392            self.atemp_min = Decimal(fit.data['min_atemp'])
393            self.atemp_max = Decimal(fit.data['max_atemp'])
394
395        return True
396
397    @property
398    def has_tracking_file(self):
399        return self.tracking_file is not None
400
401    @property
402    def has_gpx(self):
403        return self.has_tracking_file and self.tracking_filetype == 'gpx'
404
405    @property
406    def has_fit(self):
407        return self.fit_file is not None
Note: See TracBrowser for help on using the repository browser.