"""A module dedicated to format user and reference trajectories."""
from typing import Dict, List, Tuple
import numpy as np
import pandas as pd
from dispel.data.levels import Level
from dispel.signal.core import compute_rotation_matrix_2d, euclidean_distance
from dispel.signal.interpolators import cubic_splines, custom_interpolator_1d
LEVEL_TO_MODEL = {
"square_counter_clock": "squareCounterClock",
"square_clock": "squareClock",
"infinity": "infinity",
"spiral": "spiral",
}
AREA_RADIUS = 40
r"""The radius of a circle centered in the first reference point of a drawing
shape that define the valid starting area."""
CTL = (56, 639)
CBL = (56, 123)
CBR = (319, 123)
CTR = (319, 639)
r"""Corner definitions, i.e. CTL for Corner Top Left, ..."""
HALF_SCREEN_X = 187.5
r"""Define the half of the x axis of the screen."""
HALF_SCREEN_Y = 406
r"""Define the half of the y axis of the screen."""
SHAPE_BOUNDARIES = ((56, 319), (123, 639))
r"""Define boundaries of a square-like shape in order to detect if a user point
is inside or outside the shape in the screen x and y axes coordinates."""
SHAPE_SIZE = {
"ADS": {
"infinity": 1000,
"spiral": 779,
"square_clock": 1038,
"square_counter_clock": 1038,
},
"BDH": {
"infinity": 641,
"spiral": 607,
"square_clock": 124,
"square_counter_clock": 124,
},
}
r"""Define the number of points of the reference shape."""
[docs]
def generate_model_coordinates(level_name) -> Dict[str, np.ndarray]:
"""Generate the model coordinates for a given level name.
Parameters
----------
level_name
The name of the desired level.
Returns
-------
Dict[str, numpy.ndarray]
The 2 dimension numpy array corresponding to the desired level (shape).
Raises
------
ValueError
If the given level name is unrecognized.
"""
if level_name == "squareCounterClock":
return generate_square_counter_clock()
if level_name == "squareClock":
return generate_square_clock()
if level_name == "infinity":
return generate_infinity()
if level_name == "spiral":
return generate_spiral()
raise ValueError(f"Unknown level name: {level_name}")
[docs]
def generate_infinity() -> Dict[str, np.ndarray]:
"""Generate the 'infinity' level (shape)."""
alpha = 280
angle = 90
time = np.linspace(0, 2 * np.pi, num=1000)
x = alpha * np.cos(time) / (np.sin(time) ** 2 + 1)
y = alpha * np.cos(time) * np.sin(time) / (np.sin(time) ** 2 + 1)
path = [
compute_rotation_matrix_2d([0, 0], [x, y], angle * np.pi / 180)
for x, y in zip(x, y)
]
x_rot = np.asarray([path[i][0] for i in range(len(path))])
y_rot = np.asarray([path[i][1] for i in range(len(path))])
width = 375
height = 812
x_rot = x_rot + width / 2
y_rot = y_rot - height / 2
return dict(xr=x_rot, yr=y_rot)
[docs]
def generate_square_clock() -> Dict[str, np.ndarray]:
"""Generate the 'squareClock' level (shape)."""
d_inter_point = 1.5
step = 50
x_start = 56
y_start = -123
y_end = -566 - 123
n_points = int(np.abs(y_end - y_start) / d_inter_point)
y_seg_1 = np.linspace(y_start, y_end, n_points)
x_seg_1 = np.full(len(y_seg_1), x_start)
y_start = -566 - 123
x_start = 56
x_end = 56 + 263
n_points = int((x_end - x_start) / d_inter_point)
x_seg_2 = np.linspace(x_start, x_end, n_points)
y_seg_2 = np.full(len(x_seg_2), y_start)
x_start = 56 + 263
y_start = -566 - 123
y_end = -123 - step
n_points = int(np.abs(y_end - y_start) / d_inter_point)
y_seg_3 = np.linspace(y_start, y_end, n_points)
x_seg_3 = np.full(len(y_seg_3), x_start)
y_start = -123 - step
x_start = 56 + 263
x_end = 56 + step
n_points = int(np.abs(x_end - x_start) / d_inter_point)
x_seg_4 = np.linspace(x_start, x_end, n_points)
y_seg_4 = np.full(len(x_seg_4), y_start)
x_rot = np.concatenate((x_seg_1, x_seg_2, x_seg_3, x_seg_4))
y_rot = np.concatenate((y_seg_1, y_seg_2, y_seg_3, y_seg_4))
x_rot = np.asarray(x_rot)
y_rot = np.asarray(y_rot)
x_rot = np.flip(x_rot)
y_rot = np.flip(y_rot)
return dict(xr=x_rot, yr=y_rot)
[docs]
def generate_square_counter_clock() -> Dict[str, np.ndarray]:
"""Generate the 'squareCounterClock' level (shape)."""
rectangle_coordinates = generate_square_clock()
x_rot = rectangle_coordinates["xr"]
y_rot = rectangle_coordinates["yr"]
return dict(xr=375 - x_rot, yr=y_rot)
[docs]
def generate_spiral() -> Dict[str, np.ndarray]:
"""Generate the 'spiral' level (shape)."""
inter_point_distance = 0.2359
# Initialization
pi_number = 8
path_theta = ((inter_point_distance * 1.7 * (pi_number * np.pi) ** 2 / 200) / 4) * 2
path_cumulative_theta = 0.0
path_lv: List[List[float]] = [[], []]
index = 0
path_cumulative_distance = 0
# Generation of the points
while path_cumulative_distance < np.pi * pi_number:
index = index + 1
path_cumulative_distance = np.sqrt(path_cumulative_theta)
radius = path_cumulative_distance
path_lv[0].append(radius * np.cos(path_cumulative_distance))
path_lv[1].append(radius * np.sin(path_cumulative_distance))
path_cumulative_theta = path_cumulative_theta + path_theta
x = -np.asarray(path_lv[0]) * 8
y = np.asarray(path_lv[1]) * 8
angle = 90
path = [
compute_rotation_matrix_2d([0, 0], [x, y], angle * np.pi / 180)
for x, y in zip(x, y)
]
x_rot = np.asarray([path[i][0] for i in range(len(path))])
y_rot = np.asarray([path[i][1] for i in range(len(path))])
x_rot = x_rot + 375 / 2
y_rot = y_rot - 812 / 2
x_rot = 375 - x_rot
x_rot = x_rot[30:-190] - 10
y_rot = y_rot[30:-190]
return dict(xr=x_rot, yr=y_rot)
[docs]
def get_proper_level_to_model(level_id: str) -> str:
"""Extract the correct camel type key to explore the 'screen' data set.
Parameters
----------
level_id
The desired level id to consider.
Returns
-------
str
The corresponding level id in Camel type.
Raises
------
KeyError
If the given identifier doesn't match any level.
"""
for key in filter(level_id.startswith, LEVEL_TO_MODEL):
return LEVEL_TO_MODEL[key]
raise KeyError(f"{level_id} does not match any level_id.")
def _change_references(data: pd.DataFrame, height: float):
"""Set up the user and reference shapes into the device reference in pt.
It also corrects the old data format. Indeed, the expected trajectory has
been made based on a negative y axis. New formats don't provide yPosition
in a negative referential, but in a positive one. Then, that is why we
check the sign of the yPosition of the user's trajectory: if it is
negative, the trajectory is in the same referential than the expected one.
If not, a rotation is applied to the user's trajectory by changing the
positive sign of the user's yPosition into a negative one.
Then, the data format is projected to the screen references in ``points``
of the device.
Parameters
----------
data
A pandas data frame corresponding to the user or reference trajectory.
height
The height in `pts` corresponding to the screen of the user's
smartphone.
Returns
-------
pandas.DataFrame
The updated (if necessary) pandas data frame corresponding to the user
trajectory.
"""
if ((ordinate := data["y"]) < 0).any():
data["y"] = ordinate + height
else:
data["y"] = height - ordinate
return data
[docs]
def check_is_in_area(ser: pd.Series, x_ref: float, y_ref: float) -> bool:
"""Return if the user points are within a specific area.
The area is considered as the circle of 40 pts radius with x_ref and y_ref
as its center.
Parameters
----------
ser
A pandas series corresponding to a user point.
x_ref
The x coordinate of the reference trajectory to be considered as the
center of the area.
y_ref
The y coordinate of the reference trajectory to be considered as the
center of the area.
Returns
-------
bool
Whether the point is in the starting area or not.
"""
# Acceptance threshold is the area radius +- 10% tolerance
return (
np.sqrt((ser["x"] - x_ref) ** 2 + (ser["y"] - y_ref) ** 2) < AREA_RADIUS * 1.1
)
[docs]
def flag_valid_area(user: pd.DataFrame, reference: pd.DataFrame) -> pd.DataFrame:
"""Add a series named `isValidArea` to determine if a point is valid.
e.g. if a point or a Series of point belongs to a draw which has triggered
the starting area condition.
Parameters
----------
user
A pandas data frame with user trajectory, touch actions
and timestamps of touch events.
reference
The reference trajectory corresponding to the current shape.
Returns
-------
pandas.DataFrame
The same pandas data frame given as input plus a pandas Series
`isValidArea`.
Raises
------
ValueError
If no `down` touch action is detected.
"""
actions = user[["touchAction", "x", "y"]].copy()
down = actions.loc[actions["touchAction"] == "down", ["x", "y"]]
if len(down) < 1:
raise ValueError("No down touchAction")
ref_x = reference.iloc[0]["x"]
ref_y = reference.iloc[0]["y"]
down = down.apply(lambda s: check_is_in_area(s, ref_x, ref_y), axis=1)
# Flag every action posterior to the first valid event as valid
actions["isValidArea"] = False
# Find the closest down point to the center of the valid area
index_min = down.loc[down].index
if len(index_min) > 0:
first_valid = min(down.loc[down].index)
actions.loc[first_valid:, "isValidArea"] = True
return actions["isValidArea"]
[docs]
def get_user_path(
data: pd.DataFrame, reference: pd.DataFrame, height: float
) -> pd.DataFrame:
"""Extract user path coordinates, timestamps and touch actions.
Parameters
----------
data
A pandas data frame corresponding to the 'screen' raw data set.
reference
The reference trajectory corresponding to the current shape.
height
The height in `pts` corresponding to the screen of the user's
smartphone.
Returns
-------
pandas.DataFrame
A pandas data frame composed of user path coordinates, timestamps,
touch actions and the valid or non valid flag.
"""
cols = ["xPosition", "yPosition", "touchAction", "tsTouch", "pressure"]
renamed_cols = {"xPosition": "x", "yPosition": "y"}
checked_data = _change_references(data[cols].rename(columns=renamed_cols), height)
flags = flag_valid_area(checked_data, reference)
return pd.concat([checked_data, flags], axis=1)
[docs]
def get_reference_path(level: Level, height: float) -> pd.DataFrame:
"""Extract the reference trajectory x and y coordinates.
Parameters
----------
level
The desired level on which you want to compute model path.
height
The height in `pts` corresponding to the screen of the user's
smartphone.
Returns
-------
pandas.DataFrame
A pandas data frame composed of the reference trajectory x and y
coordinates.
"""
# Check if BDH FORMAT
# When it is in BDH format we will correct the orientation first by
# Rotating the model path by 90 degree vertical
if "target_figure_position_xy" in level.context:
raw = level.context.get_raw_value("target_figure_position_xy")
model_path = pd.DataFrame(raw, columns=["x", "y"])
model_path["y"] = -1 * model_path["y"]
# Interpolating to have same resolution
shape_name = level.id.id.split("-")[0]
up_sampling_factor = (
SHAPE_SIZE["ADS"][shape_name] / SHAPE_SIZE["BDH"][shape_name]
)
_ref = model_path[["x", "y"]].to_numpy()
if shape_name in {"spiral", "infinity"}:
kind = "cubic"
else:
kind = "linear"
model_path = pd.DataFrame(
custom_interpolator_1d(_ref, up_sampling_factor, kind=kind),
columns=["x", "y"],
)
# For the specific case of infinity shape
# We also correct the starting point of the model path to match the
# Small flag indicating the center of the start area.
if shape_name == "infinity":
roll_ = model_path["y"].argmax()
model_path["x"] = np.roll(model_path["x"], -roll_)
model_path["y"] = np.roll(model_path["y"], -roll_)
return _change_references(model_path, height)
raw = generate_model_coordinates(get_proper_level_to_model(level_id=str(level.id)))
return _change_references(pd.DataFrame({"x": raw["xr"], "y": raw["yr"]}), height)
[docs]
def get_valid_path(data: pd.DataFrame) -> pd.DataFrame:
"""Keep only the valid trajectory of the user.
Parameters
----------
data
The pandas data frame obtained via a
:class:`~dispel.providers.generic.tasks.draw.touch.DrawShape`.
Returns
-------
pandas.DataFrame
The pandas data frame given in input but composed of only the valid
user trajectory.
"""
return data.loc[data["isValidArea"]].reset_index(drop=True)
[docs]
def up_sample_user_path(
data: pd.DataFrame, up_sampling_factor: float = 5
) -> pd.DataFrame:
"""Compute an up-sampled Bezier curve for a given user trajectory.
Parameters
----------
data
A pandas data frame composed of the valid user trajectory and no
duplicates.
up_sampling_factor
The up sampling factor you want to apply.
Returns
-------
pandas.DataFrame
A pandas data frame composed of x and y up sampled user coordinates,
namely ``x`` and ``y``.
"""
path_user = data[["x", "y"]].to_numpy()
return pd.DataFrame(
cubic_splines(path_user, up_sampling_factor), columns=["x", "y"]
)
[docs]
def get_valid_up_sampled_path(data: pd.DataFrame) -> pd.DataFrame:
"""Extract an up sampled user trajectory.
It starts by filtering and keeping only the valid trajectory, then remove
x and y potential duplicates (needed for the interpolation), and then apply
the interpolation. It only returns a pandas data frame composed of x and y
up sampled user coordinates, namely ``x`` and ``y``.
Parameters
----------
data
The pandas data frame obtained via a
:class:`~dispel.providers.generic.tasks.draw.touch.DrawShape`.
Returns
-------
pandas.DataFrame
A pandas data frame composed of valid up sampled user coordinates
``x`` and ``y``.
"""
valid_paths = get_valid_path(data)
valid_paths = valid_paths.drop_duplicates(subset=["x", "y"])
valid_paths_up = up_sample_user_path(valid_paths)
return valid_paths_up
[docs]
def get_segment_deceleration(level_id: str) -> range:
"""Retrieve the segment of the deceleration profile of a given shape.
The aim of that is to compute intentional tremors for square-like shapes.
Segments on which to compute intentional tremors have been determined by
capturing the velocity profile of each shape against 50 Healthy Control
Participants evaluations. For each shape, the segment
is defined as model points where the mean velocity is comprised between 50%
and 20% of the maximum of the velocity profile.
Parameters
----------
level_id
The level id on which to retrieve proper the deceleration profile of
the shape.
Returns
-------
range
The list of model path indexes to consider to build the signal to study
intentional tremors.
Raises
------
KeyError
If passed level_id is not in segments.
"""
#: Those ranges have been extracted from the exploratory work on
# intentional tremors (see `Draw Tremor.ipynb` in `Exploratory Data
# Analysis`). They corresponds to segments approaching the second corner of
# the shape, and on which we expect to observe intentonal tremors from
# patients. Those segments may need to be tweaked with patient data.
segments = {
"square_clock-right": range(415, 472),
"square_clock-left": range(415, 479),
"square_counter_clock-right": range(422, 476),
"square_counter_clock-left": range(386, 490),
}
for key in filter(level_id.startswith, segments):
return segments[key]
raise KeyError(
"The intentional tremors for the desired level is not"
" implemented, please provide a level in"
" [square_clock-right, square_clock-right-02,"
" square_clock-left,square_clock-left-02,"
" square_counter_clock-right,"
" square_counter_clock-right-02, square_counter_clock-left,"
" square_counter_clock-left-02]"
)
[docs]
def remove_overshoots(data: pd.DataFrame, reference: pd.DataFrame) -> pd.DataFrame:
"""Format data to remove overshoots from user trajectory.
Set the first user point as the closest point from the head of the
reference trajectory and set the last user point as the closest point from
the tail of the reference trajectory.
Parameters
----------
data
A pandas data frame corresponding to the user data coming from an
aggregated valid touch of a
:class:`~dispel.providers.generic.tasks.draw.touch.DrawShape`.
reference
A pandas data frame corresponding to the reference trajectory coming
from a :class:`~dispel.providers.generic.tasks.draw.touch.DrawShape`.
Returns
-------
pandas.DataFrame
The user data without overshoots (head and tail).
"""
# Set x and y coordinates from the first and the last reference trajectory.
x_ref_start = reference.iloc[0]["x"]
y_ref_start = reference.iloc[0]["y"]
x_ref_end = reference.iloc[-1]["x"]
y_ref_end = reference.iloc[-1]["y"]
# Compute the distance of user points from the head of the reference.
data["distance_first"] = data.apply(
lambda s: euclidean_distance([s["x"], s["y"]], [x_ref_start, y_ref_start]),
axis=1,
)
# Compute the distance of user points from the tail of the reference.
data["distance_last"] = data.apply(
lambda s: euclidean_distance([s["x"], s["y"]], [x_ref_end, y_ref_end]), axis=1
)
# Flag which points are in the valid starting area.
data = data.join(
data.apply(
lambda s: check_is_in_area(s, x_ref_start, y_ref_start), axis=1
).rename("start_area")
)
# Flag which points are in the end area defined by the 40 pts radius circle
# with (x_ref_end, y_ref_end) point as its center.
data = data.join(
data.apply(lambda s: check_is_in_area(s, x_ref_end, y_ref_end), axis=1).rename(
"end_area"
)
)
# Determine the first user point going out from the valid starting area.
min_data = data[~data["start_area"]].index
if len(min_data) == 0:
return pd.DataFrame(columns=data.columns)
first_out_start_area = min(data[~data["start_area"]].index)
# Determine the last user point before going within the end area.
max_end_data = data[data["end_area"]].index
if len(max_end_data) == 0:
return pd.DataFrame(columns=data.columns)
max_out_end = data[(~data["end_area"]) & (data.index < max(max_end_data))].index
if len(max_out_end) == 0:
return pd.DataFrame(columns=data.columns)
last_out_end_area = max(max_out_end)
# Remove early starting points from user trajectory.
mask_start = data.loc[
(data["start_area"]) & (data.index < first_out_start_area), "distance_first"
]
idx_start = mask_start.loc[mask_start == mask_start.min()].index.item()
# Remove overshoot from user trajectory.
mask_end = data.loc[
(data["end_area"]) & (data.index > last_out_end_area), "distance_last"
]
idx_end = mask_end.loc[mask_end == mask_end.min()].index.item()
return data.iloc[idx_start : idx_end + 1]
[docs]
def remove_reference_head(data: pd.DataFrame, reference: pd.DataFrame) -> pd.DataFrame:
"""Remove the reference head if the first user point doesn't match.
The function removes the reference head when the reference point that is
the closest to the first user point is different from the first reference
point.
Parameters
----------
data
A pandas data frame containing the user trajectory
(with or without overshoot).
reference
A pandas data frame containing the reference trajectory.
Returns
-------
pandas.DataFrame
The reference trajectory without the head if needed.
"""
if len(data) == 0:
return reference
new_ref = reference.copy()
# Flag which points are in the valid starting area defined by the 40 pts
# radius circle.
new_ref = new_ref.join(
new_ref.apply(
lambda s: check_is_in_area(s, new_ref.iloc[0]["x"], new_ref.iloc[0]["y"]),
axis=1,
).rename("start_area")
)
# Compute the distance of reference points from the head of the user
# trajectory.
new_ref["distance_first"] = new_ref.apply(
lambda s: euclidean_distance(
[s["x"], s["y"]], [data.iloc[0]["x"], data.iloc[0]["y"]]
),
axis=1,
)
# Determine the first reference point going out from the valid starting
# area.
first_out_start_area = min(new_ref[~new_ref["start_area"]].index)
# Remove reference points anterior to the closest one to the first point of
# the user trajectory.
mask_start = new_ref.loc[
(new_ref["start_area"]) & (new_ref.index < first_out_start_area),
"distance_first",
]
ref_idx_first = mask_start.loc[mask_start == mask_start.min()].index.item()
return new_ref[ref_idx_first:].reset_index().rename(columns={"index": "old_index"})
# The next functions define 4 parts of the square-like shapes, splitting them
# by drawing a line at the center of each axis of shapes.
[docs]
def top_left(data: pd.DataFrame) -> pd.DataFrame:
"""Return the top left part of a square-like shape (user/reference).
Parameters
----------
data
A pandas data frame containing at least a trajectory
(x and y coordinates) of a square-like shape.
Returns
-------
pandas.DataFrame
The top left part of the square-like shape given as input.
"""
return data.loc[(data["x"] < HALF_SCREEN_X) & (data["y"] > HALF_SCREEN_Y)]
[docs]
def bottom_left(data: pd.DataFrame) -> pd.DataFrame:
"""Return the bottom left part of a square-like shape (user/reference).
Parameters
----------
data
A pandas data frame containing at least a trajectory
(x and y coordinates) of a square-like shape.
Returns
-------
pandas.DataFrame
The bottom left part of the square-like shape given as input.
"""
return data.loc[(data["x"] < HALF_SCREEN_X) & (data["y"] < HALF_SCREEN_Y)]
[docs]
def top_right(data: pd.DataFrame) -> pd.DataFrame:
"""Return the top right part of a square-like shape (user/reference).
Parameters
----------
data
A pandas data frame containing at least a trajectory
(x and y coordinates) of a square-like shape.
Returns
-------
pandas.DataFrame
The top right part of the square-like shape given as input.
"""
return data.loc[(data["x"] > HALF_SCREEN_X) & (data["y"] > HALF_SCREEN_Y)]
[docs]
def bottom_right(data: pd.DataFrame) -> pd.DataFrame:
"""Return the bottom right part of a square-like shape (user/reference).
Parameters
----------
data
A pandas data frame containing at least a trajectory
(x and y coordinates) of a square-like shape.
Returns
-------
pandas.DataFrame
The bottom right part of the square-like shape given as input.
"""
return data.loc[(data["x"] > HALF_SCREEN_X) & (data["y"] < HALF_SCREEN_Y)]
[docs]
def get_max_dist_corner(
user: pd.DataFrame, attrib: pd.DataFrame, ref: pd.DataFrame, corner: Tuple[int, int]
) -> float:
"""Retrieve the Fréchet distance within a corner zone.
The Fréchet distance is the maximum of the minimum distance between the
user and the reference trajectories based on the dynamic time warping
attributions.
Parameters
----------
user
The user trajectory user for computing the DTW attributions.
attrib
The related dynamic time warping attributions for the considered
reference trajectory with the user trajectory of interest.
ref
The reference trajectory
corner
The corner zone of the reference trajectory to consider.
Returns
-------
float
The maximum of the minimum distances from DTW attributions within the
considered corner zone.
"""
is_in = ref.apply(lambda s: check_is_in_area(s, corner[0], corner[1]), axis=1)
idx = ref[is_in].index
if idx.size == 0:
return np.nan
narrow_attrib = attrib.loc[attrib.expect.isin(idx)]
max_dist = narrow_attrib.min_distance.max()
user_idx = narrow_attrib.loc[
narrow_attrib.min_distance == max_dist, "actual"
].item()
user_point = user.loc[user_idx, ["x", "y"]]
if (
(user_point["x"] > SHAPE_BOUNDARIES[0][0])
& (user_point["x"] < SHAPE_BOUNDARIES[0][1])
& (user_point["y"] > SHAPE_BOUNDARIES[1][0])
& (user_point["y"] < SHAPE_BOUNDARIES[1][1])
):
return -max_dist
return max_dist
[docs]
def scc_corners_max_dist(
user: pd.DataFrame, ref: pd.DataFrame, attrib: pd.DataFrame
) -> Tuple[float, float, float]:
"""Compute maximum distance per corner zones.
For square counter clock shapes.
Parameters
----------
user
A pandas data frame composed of at least the user trajectory used for
computing DTW attributions.
ref
A pandas data frame composed of at least the reference trajectory.
attrib
The related dynamic time warping attributions for the considered
reference trajectory with the user trajectory of interest.
Returns
-------
Tuple[float, float, float]
A tuple composed of the extracted aforementioned distances (for the 3
related corners).
"""
dist_ctl = get_max_dist_corner(user, attrib, ref, CTL)
dist_cbl = get_max_dist_corner(user, attrib, ref, CBL)
dist_cbr = get_max_dist_corner(user, attrib, ref, CBR)
return dist_ctl, dist_cbl, dist_cbr
[docs]
def sc_corners_max_dist(
user: pd.DataFrame, ref: pd.DataFrame, attrib: pd.DataFrame
) -> Tuple[float, float, float]:
"""Compute maximum distance per corner zones.
For square clock shapes.
Parameters
----------
user
A pandas data frame composed of at least the user trajectory used for
computing DTW attributions.
ref
A pandas data frame composed of at least the reference trajectory.
attrib
The related dynamic time warping attributions for the considered
reference trajectory with the user trajectory of interest.
Returns
-------
Tuple[float, float, float]
A tuple composed of the extracted aforementioned distances (for the 3
related corners).
"""
dist_ctr = get_max_dist_corner(user, attrib, ref, CTR)
dist_cbr = get_max_dist_corner(user, attrib, ref, CBR)
dist_cbl = get_max_dist_corner(user, attrib, ref, CBL)
return dist_ctr, dist_cbr, dist_cbl