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

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

Added attribute to store speed info for Workout objects.

Load avg and max speed from fit files into Workout objects.

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