Source code for floodlight.models.kinetics
import numpy as np
from scipy.constants import g
from floodlight.utils.types import Numeric
from floodlight.core.xy import XY
from floodlight.core.property import PlayerProperty
from floodlight.models.base import BaseModel, requires_fit
from floodlight.models.kinematics import VelocityModel, AccelerationModel
[docs]class MetabolicPowerModel(BaseModel):
"""Class for calculating Metabolic Power and derived metrics from spatiotemporal
data.
Upon calling the :func:`~MetbolicPowerModel.fit`-method, this model calculates the
frame-wise Metabolic Power for each player. The following calculations can
subsequently be queried by calling the corresponding methods:
- Frame-wise Metabolic Power --> :func:`~MetabolicPowerModel.metabolic_power`
- Cumulative Metabolic Power --> :func:`~MetabolicPowerModel.cumulative_\
metabolic_power`
- Frame-wise Equivalent Distance --> :func:`~MetabolicPowerModel.equivalent_\
distance`
- Cumulative Equivalent Distance --> :func:`~MetabolicPowerModel.cumulative_\
equivalent_distance`
Notes
-----
Metabolic Power is defined as the energy expenditure over time necessary to move at
a certain speed, and is calculated as the product of energy cost of transport per
unit body mass and distance [:math:`\\frac{J}{kg \\cdot m}`] and velocity
[:math:`\\frac{m}{s}`]. Metabolic Power and Energy cost of walking is calculated
according to di Prampero & Osgnach [1]_. Energy cost of running is calculated with
the updated formula of Minetti & Parvei [2]_.
Examples
--------
>>> import numpy as np
>>> from floodlight import XY
>>> from floodlight.models.kinetics import MetabolicPowerModel
>>> xy = XY(np.array(((0, 0), (0, 1), (1, 1), (2, 2))), framerate=20)
>>> metabolic_power_model = MetabolicPowerModel()
>>> metabolic_power_model.fit(xy)
>>> metabolic_power_model.metabolic_power()
PlayerProperty(property=array([[1164.59773017],
[ 185.59792131],
[9448.10007077],
[8593.05199423]]), name='metabolic_power', framerate=20)
>>> metabolic_power_model.cumulative_equivalent_distance()
PlayerProperty(property=array([[ 323.49936949],
[ 375.05434763],
[2999.52658952],
[5386.4854768 ]]), name='cumulative_equivalent_distance', framerate=20)
References
----------
.. [1] `di Prampero P.E., Osgnach C. (2018). Metabolic power in team sports -
Part 1: An update. International Journal of Sports Medicine, 39(08),
581-587.
<https://www.thieme-connect.de/products/ejournals/abstract/10.1055/
a-0592-7660>`_
.. [2] `Minetti, A.E., Parvei, G. (2018). Update and extension of the
‘Equivalent Slope’ of speed changing level locomotion in humans: A
computational model for shuttle running. Journal Experimental Biology,
221:jeb.182303.
<https://journals.biologists.com/jeb/article/221/15/jeb182303/19414/Update-
and-extension-of-the-equivalent-slope-of>`_
"""
# Coefficient of air resistance from di Prampero (2018).
K = 0.0037
# Coefficients of polynomial to calculate the walk-run-transition
# velocity based on the equivalent slope from di Prampero (2018).
RUNNING_TRANSITION_COEFF = np.array((-107.05, 113.13, -1.13, -15.84, -1.7, 2.27))
# Cutoffs of equivalent slope for using the corresponding polynomial to calculate
# energy cost of walking at a certain velocity from di Prampero (2018).
ECW_ES_CUTOFFS = np.array([-0.3, -0.2, -0.1, 0, 0.1, 0.2, 0.3, 0.4])
# Coefficients of polynomials to calculate energy cost of walking from di
# Prampero (2018).
ECW_POLY_COEFF = np.array(
[
[0.28, -1.66, 3.81, -3.96, 4.01],
[0.03, -0.15, 0.98, -2.25, 3.14],
[0.69, -3.21, 5.94, -5.07, 2.79],
[1.25, -6.57, 13.14, -11.15, 5.35],
[0.68, -4.17, 10.17, -10.31, 8.66],
[3.80, -14.91, 22.94, -14.53, 11.24],
[44.95, -122.88, 126.94, -57.46, 21.39],
[94.62, -213.94, 184.43, -68.49, 25.04],
]
)
def __init__(self):
super().__init__()
self._metabolic_power_ = None
@staticmethod
def _calc_es(vel, acc):
"""Calculates equivalent slope based on the formula by di Prampero & Osgnach
(2018)
Parameters
----------
vel: np.array
velocity
acc: np.array
acceleration
Returns
-------
es: np.array
equivalent slope
"""
es = (acc / g) + ((MetabolicPowerModel.K * np.square(vel)) / g)
return es
@staticmethod
def _calc_em(es):
"""Calculates equivalent mass based on the formula by di Prampero & Osgnach
(2018)
Parameters
----------
es: np.array
equivalent slope
Returns
-------
em: np.array
equivalent mass
"""
em = np.sqrt(np.square(es) + 1)
return em
@staticmethod
def _calc_v_trans(es: np.ndarray) -> np.ndarray:
"""Calculate the walking to running transition velocity at a certain equivalent
slope based on the formula of di Prampero (2018).
Parameters
----------
es: np.array
equivalent slope
Returns
-------
v_trans: np.array
Array with the respective transition velocity
"""
es_power = np.stack(
(
np.power(es, 5),
np.power(es, 4),
np.power(es, 3),
np.power(es, 2),
es,
np.ones(es.shape),
),
axis=-1,
)
v_trans = np.matmul(es_power, MetabolicPowerModel.RUNNING_TRANSITION_COEFF)
return v_trans
@staticmethod
def _is_running(vel: np.ndarray, es: np.ndarray) -> np.ndarray:
"""
Checks if athlete is walking or running based on the model of di Prampero
(2018).
Parameters
----------
vel: np.array
Velocity
es: np.array
Equivalent slope
Returns
-------
is_running: np.ndarray
Array containing boolean values indicating whether an athlete is running
(True) or not (False).
"""
# Calculate walk-run-transition velocity
v_trans = MetabolicPowerModel._calc_v_trans(es)
is_running = (vel >= v_trans) | (vel > 2.5)
return is_running
@staticmethod
def _get_interpolation_weight_matrix(es: np.ndarray) -> np.ndarray:
"""Calculates interpolation weight matrix. This matrix is designed for a
calculation of ECW in a single sweep by determining the interpolation weights
of all 8 ECW_ES_CUTOFFS for given ES values.
Parameters
----------
es: np.array
Equivalent slope
Returns
-------
W: np.array
Interpolation weight matrix of shape (T frames, N players,
len(ECW_ES_CUTOFFS)=8) containing interpolation coefficients from range
[0, 1].
"""
# Number of frames
T = es.shape[0]
# Number of players
N = es.shape[1]
# Pre-allocated interpolation weight matrix with 3 dimensions (T frames, N
# players, len(CUTOFFS)=8 polynomials)
W = np.zeros((T, N, len(MetabolicPowerModel.ECW_ES_CUTOFFS)))
# Index of each ES regarding its position in CUTOFFS.
# E.g. es = 0.25 -> es will be sorted between CUTOFFS[5] and CUTOFFS[6],
# idxs = 6
idxs = MetabolicPowerModel.ECW_ES_CUTOFFS.searchsorted(es)
# Mask for non-edge cases (es outside of CUTOFFS)
mask = (idxs > 0) & (idxs < 8)
# Initialize grids for appropriate indexing of W along axis=0 (time) and
# axis=1 (player)
grid_t, grid_n = np.mgrid[0:T, 0:N]
# Fill W with the right interpolation weights for each time t (axis=0),
# player n (axis=1) and polynomial (axis=2)
W[grid_t[mask], grid_n[mask], idxs[mask] - 1] = (
MetabolicPowerModel.ECW_ES_CUTOFFS[idxs[mask]] - es[mask]
) * 10
W[grid_t[mask], grid_n[mask], idxs[mask]] = (
es[mask] - MetabolicPowerModel.ECW_ES_CUTOFFS[idxs[mask] - 1]
) * 10
# Fill edge cases (es not in range of CUTOFFS) with 1 because they are
# calculated with the corresponding min/max CUTOFFS
W[idxs == 0, 0] = 1
W[idxs == 8, 7] = 1
return W
@staticmethod
def _calc_ecw(es: np.ndarray, vel: np.ndarray, em: np.ndarray) -> np.ndarray:
"""Calculates energy cost of walking based on formula (13), (14) and table
1 in di Prampero & Osgnach (2018).
Parameters
----------
es: np.array
Equivalent slope
vel: np.array
Velocity
em: np.array
Equivalent mass
Returns
-------
ECW: np.array
Energy cost of walking
"""
# Interpolation weight matrix
W = MetabolicPowerModel._get_interpolation_weight_matrix(es)
# Matrix product of ECW_ES_CUTOFFS and W, ie. weighted factors in polynomials
WC = np.matmul(W, MetabolicPowerModel.ECW_POLY_COEFF)
# Calculate vel^4 + vel^3 + vel^2 + vel + 1 for every frame and player
V = np.stack(
(
np.power(vel, 4),
np.power(vel, 3),
np.power(vel, 2),
vel,
np.ones(vel.shape),
),
axis=-1,
)
# Multiply WC and V. Calculate sum of terms. Multiply with em
ECW = np.multiply(np.multiply(WC, V).sum(axis=2), em)
return ECW
@staticmethod
def _calc_ecr(es: np.ndarray, em: np.ndarray, eccr: Numeric = 3.6) -> np.ndarray:
"""Calculates Energy cost of running based on formula (3) and (4) from
Minetti & Parvei (2018).
Parameters
----------
es: np.array
Equivalent slope
em: np.array
Equivalent mass
eccr: Numeric
Energy cost of constant running. Default is set to 3.6
:math:`\\frac{J}{kg \\cdot m}` according to di Prampero (2018). Can differ
for different turfs.
Returns
-------
ecr: np.array
Energy cost of running
"""
# Cost of negative gradient from Minetti (2018)
def _cng(es: np.ndarray):
return -8.34 * es + eccr * np.exp(13 * es)
# Cost of positive gradient
def _cpg(es: np.ndarray):
return 39.5 * es + eccr * np.exp(-4 * es)
# Energy cost of running. Where es < 0 calculate cost of negative gradient.
# Where es >= 0 calculate cost of positive gradient.
ecr = np.piecewise(es, [es < 0, es >= 0], [_cng, _cpg]) * em
return ecr
@staticmethod
def _calc_ecl(
es: np.ndarray, vel: np.ndarray, em: np.ndarray, eccr: Numeric = 3.6
) -> np.ndarray:
"""Calculate Energy cost of locomotion.
Parameters
----------
es: np.array
Equivalent slope
vel: np.array
Velocity
em: np.array
Equivalent mass
eccr: Numeric
Energy cost of constant running. Default is set to 3.6
:math:`\\frac{J}{kg \\cdot m}` according to di Prampero (2018). Can differ
for different turfs.
Returns
-------
ecl: np.array
Energy cost of locomotion
"""
# Check where locomotion is running
running = MetabolicPowerModel._is_running(vel, es)
# Calculate energy cost of walking for entire array
ecl = MetabolicPowerModel._calc_ecw(es, vel, em)
# Substitute ecw with energy cost of running where locomotion is running
ecl[running] = MetabolicPowerModel._calc_ecr(es[running], em[running], eccr)
return ecl
@staticmethod
def _calc_metabolic_power(
es: np.ndarray,
vel: np.ndarray,
em: np.ndarray,
framerate: Numeric,
eccr: Numeric = 3.6,
) -> np.ndarray:
"""Calculates metabolic power as the product of energy cost of locomotion
and velocity.
Parameters
----------
es: np.array
Equivalent slope
vel: np.array
Velocity
em: np.array
Equivalent mass
eccr: Numeric
Energy cost of constant running. Default is set to 3.6
:math:`\\frac{J}{kg \\cdot m}` according to di Prampero (2018). Can differ
for different turfs.
Returns
-------
metp: np.array
Metabolic power
"""
# Calculate energy cost of locomotion
ecl = MetabolicPowerModel._calc_ecl(es, vel, em, eccr)
# Calculate metabolic power as product of ecl and velocity (m/s)
metp = ecl * vel
return metp
[docs] def fit(
self, xy: XY, difference: str = "central", axis: str = None, eccr: Numeric = 3.6
):
"""Fit the model to the given data and calculate metabolic power for every
player.
Notes
-----
To give appropriate results, unit of coordinates must be in meter.
Parameters
----------
xy: XY
Floodlight XY Data object.
difference: {'central', 'forward}, optional
The method of differentiation to calculate velocity and acceleration.
See :func:`~floodlight.models.kinematics.VelocityModel` for further details.
axis: {None, 'x', 'y'}, optional
Optional argument that restricts distance calculation to either the x-
or y-dimension of the data. If set to None (default), distances are
calculated in both dimensions.
eccr: Numeric
Energy cost of constant running. Default is set to 3.6
:math:`\\frac{J}{kg \\cdot m}` according to di Prampero (2018). Can differ
for different turfs.
"""
# Velocity
velocity_model = VelocityModel()
velocity_model.fit(xy, difference=difference, axis=axis)
velocity = velocity_model.velocity()
# Acceleration
acceleration_model = AccelerationModel()
acceleration_model.fit(xy, difference=difference, axis=axis)
acceleration = acceleration_model.acceleration()
# Equivalent slope
equivalent_slope = MetabolicPowerModel._calc_es(
velocity.property, acceleration.property
)
# Equivalent mass
equivalent_mass = MetabolicPowerModel._calc_em(equivalent_slope)
# Metabolic power
metabolic_power = MetabolicPowerModel._calc_metabolic_power(
equivalent_slope, velocity.property, equivalent_mass, xy.framerate, eccr
)
self._metabolic_power_ = PlayerProperty(
property=metabolic_power,
name="metabolic_power",
framerate=xy.framerate,
)
[docs] @requires_fit
def metabolic_power(self) -> PlayerProperty:
"""Returns the frame-wise metabolic power as computed by the ``fit()``-method.
Returns
-------
metabolic_power: PlayerProperty
A Player Property object of shape (T, N), where T is the total number of
frames and N is the number of players. The columns contain the frame-wise
metabolic power.
"""
metabolic_power = self._metabolic_power_
return metabolic_power
[docs] @requires_fit
def cumulative_metabolic_power(self) -> PlayerProperty:
"""Returns the cumulative metabolic power.
Returns
-------
metabolic_power: PlayerProperty
A Player Property object of shape (T, N), where T is the total number of
frames and N is the number of players. The columns contain the cumulative
metabolic power calculated by numpy.nancumsum() over axis=0.
"""
cum_metp = np.divide(
np.nancumsum(self._metabolic_power_.property, axis=0),
self._metabolic_power_.framerate
)
cumulative_metabolic_power = PlayerProperty(
property=cum_metp,
name="cumulative_metabolic_power",
framerate=self._metabolic_power_.framerate,
)
return cumulative_metabolic_power
[docs] @requires_fit
def equivalent_distance(self, eccr: Numeric = 3.6) -> PlayerProperty:
"""Returns frame-wise equivalent distance, defined as the distance a player
could have run if moving at a constant speed and calculated as the fraction of
metabolic work and the cost of constant running.
Parameters
----------
eccr: Numeric
Energy cost of constant running. Default is set to 3.6
:math:`\\frac{J}{kg \\cdot m}` according to di Prampero (2018). Can differ
for different turfs.
Returns
-------
equivalent_distance: PlayerProperty
A Player Property object of shape (T, N), where T is the total number of
frames and N is the number of players. The columns contain the frame-wise
equivalent distance.
"""
eq_dist = self._metabolic_power_.property / eccr
cumulative_metabolic_power = PlayerProperty(
property=eq_dist,
name="equivalent_distance",
framerate=self._metabolic_power_.framerate,
)
return cumulative_metabolic_power
[docs] @requires_fit
def cumulative_equivalent_distance(self, eccr: Numeric = 3.6) -> PlayerProperty:
"""Returns cumulative equivalent distance defined as the distance a player
could have run if moving at a constant speed and calculated as the fraction
of metabolic work and the cost of constant running.
Parameters
----------
eccr: Numeric
Energy cost of constant running. Default is set to 3.6
:math:`\\frac{J}{kg \\cdot m}` according to di Prampero (2018). Can differ
for different turfs.
Returns
-------
cumulative_equivalent_distance: PlayerProperty
A Player Property object of shape (T, N), where T is the total number of
frames and N is the number of players. The columns contain the cumulative
equivalent distance calculated by numpy.nancumsum() over axis=0.
"""
cum_metp = np.divide(
np.nancumsum(self._metabolic_power_.property, axis=0),
self._metabolic_power_.framerate
)
cum_eqdist = cum_metp / eccr
cumulative_equivalent_distance = PlayerProperty(
property=cum_eqdist,
name="cumulative_equivalent_distance",
framerate=self._metabolic_power_.framerate,
)
return cumulative_equivalent_distance