Skip to content

Commit

Permalink
EHN add gradient elevation, speed and heart-rate (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
glemaitre committed Feb 11, 2018
1 parent a5e639b commit 0f64a33
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 18 deletions.
18 changes: 18 additions & 0 deletions skcycling/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Module containing the exceptions used in scikit-cycling."""

# Authors: Guillaume Lemaitre <g.lemaitre58@gmail.com>
# Cedric Lemaitre
# License: BSD 3 clause

__all__ = ['MissingDataError']


class MissingDataError(ValueError):
"""Error raised when there is not the required data to make some
computation.
For instance, :func:`skcycling.extraction.gradient_elevation` required
elevation and distance data which might not be provided. In this case, this
type of error is raised.
"""
10 changes: 9 additions & 1 deletion skcycling/extraction/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
# Cedric Lemaitre
# License: BSD 3 clause

from .gradient import acceleration
from .gradient import gradient_elevation
from .gradient import gradient_heart_rate

from .power_profile import activity_power_profile

__all__ = ['activity_power_profile']

__all__ = ['acceleration',
'gradient_elevation',
'gradient_heart_rate',
'activity_power_profile']
121 changes: 121 additions & 0 deletions skcycling/extraction/gradient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Function to extract gradient information about different features."""

# Authors: Guillaume Lemaitre <g.lemaitre58@gmail.com>
# Cedric Lemaitre
# License: BSD 3 clause

from __future__ import division

from ..exceptions import MissingDataError


def acceleration(activity, periods=5, append=True):
"""Compute the acceleration (i.e. speed gradient).
Parameters
----------
activity : DataFrame
The activity containing speed information.
periods : int, default=5
Periods to shift to compute the acceleration.
append : bool, optional
Whether to append the acceleration to the original activity (default)
or to only return the acceleration as a Series.
Returns
-------
data : DataFrame or Series
The original activity with an additional column containing the
acceleration or a single Series containing the acceleration.
"""
if 'speed' not in activity.columns:
raise MissingDataError('To compute the acceleration, speed data are '
'required. Got {} fields.'
.format(activity.columns))

acceleration = activity['speed'].diff(periods=periods)

if append:
activity['acceleration'] = acceleration
return activity
else:
return acceleration


def gradient_elevation(activity, periods=5, append=True):
"""Compute the elevation gradient.
Parameters
----------
activity : DataFrame
The activity containing elevation and distance information.
periods : int, default=5
Periods to shift to compute the elevation gradient.
append : bool, optional
Whether to append the elevation gradient to the original activity
(default) or to only return the elevation gradient as a Series.
Returns
-------
data : DataFrame or Series
The original activity with an additional column containing the
elevation gradient or a single Series containing the elevation
gradient.
"""
if not {'elevation', 'distance'}.issubset(activity.columns):
raise MissingDataError('To compute the elevation gradient, elevation '
'and distance data are required. Got {} fields.'
.format(activity.columns))

diff_elevation = activity['elevation'].diff(periods=periods)
diff_distance = activity['distance'].diff(periods=periods)
gradient_elevation = diff_elevation / diff_distance

if append:
activity['gradient-elevation'] = gradient_elevation
return activity
else:
return gradient_elevation


def gradient_heart_rate(activity, periods=5, append=True):
"""Compute the heart-rate gradient.
Parameters
----------
activity : DataFrame
The activity containing heart-rate information.
periods : int, default=5
Periods to shift to compute the heart-rate gradient.
append : bool, optional
Whether to append the heart-rate gradient to the original activity
(default) or to only return the heart-rate gradient as a Series.
Returns
-------
data : DataFrame or Series
The original activity with an additional column containing the
heart-rate gradient or a single Series containing the heart-rate
gradient.
"""
if 'heart-rate' not in activity.columns:
raise MissingDataError('To compute the heart-rate gradient, heart-rate'
' data are required. Got {} fields.'
.format(activity.columns))

gradient_heart_rate = activity['heart-rate'].diff(periods=periods)

if append:
activity['gradient-heart-rate'] = gradient_heart_rate
return activity
else:
return gradient_heart_rate
2 changes: 0 additions & 2 deletions skcycling/extraction/power_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@
# Cedric Lemaitre
# License: BSD 3 clause

from collections import defaultdict
from datetime import time, timedelta
from numbers import Integral

import numpy as np
import pandas as pd
import six
from joblib import Parallel, delayed

from ._power_profile import max_mean_power_interval
from ._power_profile import _associated_data_power_profile
Expand Down
74 changes: 74 additions & 0 deletions skcycling/extraction/tests/test_gradients.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Test the gradient module."""

# Authors: Guillaume Lemaitre <g.lemaitre58@gmail.com>
# Cedric Lemaitre
# License: BSD 3 clause

import numpy as np
import pandas as pd

import pytest

from skcycling.extraction import acceleration
from skcycling.extraction import gradient_elevation
from skcycling.extraction import gradient_heart_rate
from skcycling.exceptions import MissingDataError


def test_acceleration_error():
activity = pd.DataFrame({'A': np.random.random(1000)})
msg = "speed data are required"
with pytest.raises(MissingDataError, message=msg):
acceleration(activity)


@pytest.mark.parametrize(
"activity, append, type_output, shape",
[(pd.DataFrame({'speed': np.random.random(100)}),
False, pd.Series, (100,)),
(pd.DataFrame({'speed': np.random.random(100)}),
True, pd.DataFrame, (100, 2))])
def test_acceleration(activity, append, type_output, shape):
output = acceleration(activity, append=append)
assert isinstance(output, type_output)
assert output.shape == shape


def test_gradient_elevation_error():
activity = pd.DataFrame({'A': np.random.random(1000)})
msg = "elevation and distance data are required"
with pytest.raises(MissingDataError, message=msg):
gradient_elevation(activity)


@pytest.mark.parametrize(
"activity, append, type_output, shape",
[(pd.DataFrame({'elevation': np.random.random(100),
'distance': np.random.random(100)}),
False, pd.Series, (100,)),
(pd.DataFrame({'elevation': np.random.random(100),
'distance': np.random.random(100)}),
True, pd.DataFrame, (100, 3))])
def test_gradient_elevation(activity, append, type_output, shape):
output = gradient_elevation(activity, append=append)
assert isinstance(output, type_output)
assert output.shape == shape


def test_gradient_heart_rate_error():
activity = pd.DataFrame({'A': np.random.random(1000)})
msg = "heart-rate data are required"
with pytest.raises(MissingDataError, message=msg):
gradient_heart_rate(activity)


@pytest.mark.parametrize(
"activity, append, type_output, shape",
[(pd.DataFrame({'heart-rate': np.random.random(100)}),
False, pd.Series, (100,)),
(pd.DataFrame({'heart-rate': np.random.random(100)}),
True, pd.DataFrame, (100, 2))])
def test_gradient_heart_rate(activity, append, type_output, shape):
output = gradient_heart_rate(activity, append=append)
assert isinstance(output, type_output)
assert output.shape == shape
8 changes: 4 additions & 4 deletions skcycling/extraction/tests/test_power_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

@pytest.mark.parametrize(
"max_duration, power_profile_shape, first_element",
[(None, (11280,), 174.404255),
(10, (45,), 450.5555),
('00:00:10', (45,), 450.5555),
(time(0, 0, 10), (45,), 450.5555)]
[(None, (13536,), 8.2117765957446736),
(10, (54,), 5.8385555555555557),
('00:00:10', (54,), 5.8385555555555557),
(time(0, 0, 10), (54,), 5.8385555555555557)]
)
def test_activity_power_profile(max_duration, power_profile_shape,
first_element):
Expand Down
13 changes: 7 additions & 6 deletions skcycling/io/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@ def bikeread(filename, drop_nan=None):
>>> from skcycling.io import bikeread
>>> activity = bikeread(load_fit()[0], drop_nan='columns')
>>> activity.head() # doctest : +NORMALIZE_WHITESPACE
cadence distance power
2014-05-07 12:26:22 45.0 3.05 256.0
2014-05-07 12:26:23 42.0 6.09 185.0
2014-05-07 12:26:24 44.0 9.09 343.0
2014-05-07 12:26:25 45.0 11.94 344.0
2014-05-07 12:26:26 48.0 15.03 389.0
elevation cadence distance power speed
2014-05-07 12:26:22 64.8 45.0 3.05 256.0 3.036
2014-05-07 12:26:23 64.8 42.0 6.09 185.0 3.053
2014-05-07 12:26:24 64.8 44.0 9.09 343.0 3.004
2014-05-07 12:26:25 64.8 45.0 11.94 344.0 2.846
2014-05-07 12:26:26 65.8 48.0 15.03 389.0 3.088
"""
if drop_nan is not None and drop_nan not in DROP_OPTIONS:
raise ValueError('"drop_nan" should be one of {}.'
Expand Down
9 changes: 7 additions & 2 deletions skcycling/io/fit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from fitparse import FitFile

# 'timestamp' will be consider as the index of the DataFrame later on
FIELDS_DATA = ('timestamp', 'power', 'heart-rate', 'cadence', 'distance',
'elevation')
FIELDS_DATA = ('timestamp', 'power', 'heart_rate', 'cadence', 'distance',
'altitude', 'speed')


def check_filename_fit(filename):
Expand Down Expand Up @@ -78,6 +78,11 @@ def load_power_from_fit(filename):
if data.empty:
raise IOError('The file {} does not contain any data.'.format(
filename))

# rename the columns for consistency
data.rename(columns={'heart_rate': 'heart-rate', 'altitude': 'elevation'},
inplace=True)

data.set_index(FIELDS_DATA[0], inplace=True)
del data.index.name

Expand Down
6 changes: 3 additions & 3 deletions skcycling/tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ def test_rider_add_activities_update():
rider = Rider.from_csv(load_rider())
rider.delete_activities('07 May 2014')
rider.add_activities(load_fit()[0])
assert rider.power_profile_.shape == (33515, 3)
assert rider.power_profile_.shape == (35771, 3)

with pytest.raises(ValueError, message='activity was already added'):
rider.add_activities(load_fit()[0])


@pytest.mark.parametrize(
"rider, filename, expected_shape",
[(Rider(), load_fit(), (33515, 3)),
(Rider(), load_fit()[0], (11280, 1))])
[(Rider(), load_fit(), (40218, 3)),
(Rider(), load_fit()[0], (13536, 1))])
def test_rider_add_activities(rider, filename, expected_shape):
rider.add_activities(filename)
assert rider.power_profile_.shape == expected_shape
Expand Down

0 comments on commit 0f64a33

Please sign in to comment.