"""A module dedicated to format user pinch into attempts."""
import datetime
import re
from dataclasses import dataclass, field
from functools import cached_property, partial
from typing import ClassVar, List, Optional, Sequence, Tuple, Type, Union, cast
import numpy as np
import pandas as pd
from dispel.data.levels import Context, Level
from dispel.processing.modalities import HandModality
from dispel.providers.generic.tasks.pinch.modalities import (
AttemptOutcomeModality,
AttemptSelectionModality,
BubbleSizeModality,
FingerModality,
)
from dispel.providers.generic.touch import Gesture, Touch
from dispel.signal.core import euclidean_distance
from dispel.utils import plural
def _remove_attempt(level_id: str):
"""Remove attempt enumerator from level id."""
return re.sub(r"-\d+$", "", level_id)
[docs]
@dataclass
class PinchTouch(Touch):
"""A pinch touch interaction."""
#: Is successful if the gesture of the touch led to the burst of the target
is_successful: bool = field(init=False)
def __post_init__(self, data: pd.DataFrame):
super().__post_init__(data)
# Check if the pinching was successful
assert "ledToSuccess" in data.columns, "Missing ledToSuccess column"
self.is_successful = data["ledToSuccess"].any()
self._data.drop(columns="ledToSuccess", inplace=True)
@cached_property
def valid_pinch_timestamps(self) -> pd.Series:
"""Get the valid pinching time stamps.
Returns
-------
pandas.Series
The timestamps of the valid pinching moments.
Raises
------
KeyError
If `isValid Pinch` is missing from the touch data frame.
"""
if not self.is_valid_pinch_exists:
raise KeyError(
"`isValidPinch` variable is missing from the touch data frame."
)
return self._data[self._data["isValidPinch"]].index.to_series()
@property
def pinch_begin(self) -> datetime.datetime:
"""Get the start of the pinching.
Returns
-------
datetime.datetime
The time stamp when the pinching started.
"""
return self.valid_pinch_timestamps.min()
@property
def pinch_end(self) -> datetime.datetime:
"""Get the start of the pinching.
Returns
-------
datetime.datetime
The time stamp when the pinching started.
"""
return self.valid_pinch_timestamps.max()
[docs]
def set_is_valid_pinch(self, is_valid_pinch: pd.Series):
"""Set is valid pinch variable inside touch data frame.
Parameters
----------
is_valid_pinch
A pandas series containing isValidPinch boolean values and time
stamps as indexes.
"""
intersected_idx = self._data.index.intersection(is_valid_pinch.index)
self._data["isValidPinch"] = is_valid_pinch[intersected_idx]
@property
def is_valid_pinch_exists(self) -> bool:
"""Check whether is valid pinch variable exists in touch data frame."""
return "isValidPinch" in self._data.columns
[docs]
@dataclass
class PinchAttempt(Gesture):
"""A pinching gesture."""
TOUCH_CLS: ClassVar[Type[Touch]] = PinchTouch
#: Is valid if it is comprised of two touch interactions
is_valid: bool
#: Is successful if the gesture led to the burst of the target
is_successful: bool
#: The top finger touch interaction
top_finger: PinchTouch
#: The bottom finger touch interaction
bottom_finger: PinchTouch
#: The time stamp when the pinching started
pinch_begin: datetime.datetime
#: The time stamp when the pinching ended
pinch_end: datetime.datetime
[docs]
@staticmethod
def add_is_valid_pinch(
first: PinchTouch,
second: PinchTouch,
target_radius: float,
target_coords: Tuple[float, float],
) -> Tuple[PinchTouch, PinchTouch]:
"""Add is valid pinch variable to the given touches.
Parameters
----------
first
The first pinch touch.
second
The second pinch touch.
target_radius
The radius of the pinch target.
target_coords
The coordinates of the pinch target.
Returns
-------
Tuple[PinchTouch, PinchTouch]
The modified given pinch touches.
"""
first_data = first.get_data()
second_data = second.get_data()
# Initialize the isValidPinch output to false
is_valid_pinch = pd.Series(
[False] * len(index_ := first_data.index.union(second_data.index)),
index=index_,
name="isValidPinch",
)
def _inside_target(pos1, pos2) -> bool:
"""Check if two positions are inside the pinch target."""
return (
euclidean_distance(pos1, target_coords) < target_radius
and euclidean_distance(pos2, target_coords) < target_radius
)
def _first_positions_valid(
pos1: Tuple[float, float], pos2: Tuple[float, float]
) -> bool:
"""Check if the first contact points with the screen are valid.
Parameters
----------
pos1
The first touch position.
pos2
The second touch position.
Returns
-------
bool
``True`` if the first points of contact are valid.
``False`` otherwise.
Notes
-----
A valid initial points of contact varify the following criteria:
- Distance between the two points is greater than the diameter.
- Distance between each point and the radius is greater than
the radius.
"""
return (
euclidean_distance(pos1, pos2) > 2 * target_radius
and euclidean_distance(pos1, target_coords) > target_radius
and euclidean_distance(pos2, target_coords) > target_radius
)
if _first_positions_valid(first.first_position, second.first_position):
first_position, second_position = None, None
# Iterate over the sorted merged indexes between the two fingers.
for index in is_valid_pinch.index:
# Obtain coordinates for the timestamp index if existent in
# first touch
if index in first_data.index:
first_position = tuple(first_data.loc[index][["x", "y"]])
# Obtain coordinates for the timestamp index if existent in
# first touch
if index in second_data.index:
second_position = tuple(second_data.loc[index][["x", "y"]])
# If two positions are existent evaluate whether they are
# inside pinch target
if first_position and second_position:
is_valid_pinch[index] = _inside_target(
first_position, second_position
)
# Set isValidPinch variable in the given touches
first.set_is_valid_pinch(is_valid_pinch)
second.set_is_valid_pinch(is_valid_pinch)
return first, second
[docs]
@classmethod
def gesture_factory(cls, touches: Sequence[Touch], **kwargs) -> "Gesture":
"""Get a pinch attempt gesture from touches."""
# Check if we have just two touches!
is_valid = len(touches) == 2
assert is_valid, "A Pinch attempt should only have two touches"
# Check if the pinching was successful
is_successful = any(cast(PinchTouch, t).is_successful for t in touches)
if not isinstance((context := kwargs.get("context")), Context):
raise ValueError("Missing context.")
target_radius = context.get_raw_value("targetRadius")
target_coords = (
context.get_raw_value("xTargetBall"),
context.get_raw_value("yTargetBall"),
)
# assign top and bottom filters
def _first_y(touch: Touch) -> float:
return touch.positions["y"].iloc[0]
first, second = cast(Sequence[PinchTouch], touches)
if _first_y(first) < _first_y(second):
top_finger = first
bottom_finger = second
else:
top_finger = second
bottom_finger = first
# Compute is valid pinch variable if not found
if not (first.is_valid_pinch_exists and second.is_valid_pinch_exists):
first, second = cls.add_is_valid_pinch(
first, second, target_radius, target_coords
)
# determine pinch begin and end
pinch_begin = min(first.pinch_begin, second.pinch_begin)
pinch_end = max(first.pinch_end, second.pinch_end)
return cls(
touches,
is_valid=is_valid,
is_successful=is_successful,
top_finger=top_finger,
bottom_finger=bottom_finger,
pinch_begin=pinch_begin,
pinch_end=pinch_end,
)
[docs]
@classmethod
def from_data_frame(cls, data: pd.DataFrame, **kwargs) -> List[Gesture]:
"""Create PinchAttempt from a data frame.
Parameters
----------
data
A data frame containing touch events.
kwargs
Additional key word arguments passed to
:meth:`~dispel.providers.generic.touch.Gesture.gesture_factory`.
Returns
-------
List[PinchAttempt]
A sequence of PinchAttempt based on the provided ``data``. The data
frame is split according to the ``touchPathId`` into separate touch
interactions. Consecutively overlapping touches are combined as
PinchAttempt.
"""
assert (
"touchPathId" in data.columns
), "A pinch attempt need touchPathId information"
touches = cls._expand_touches(data)
assert len(touches) > 0, "No touch interaction contained in data"
gestures = []
gesture_touches: List[Touch] = []
for result in sorted(touches, key=lambda x: x.begin):
if not gesture_touches or gesture_touches[-1].overlaps(result):
gesture_touches.append(result)
else:
if len(gesture_touches) == 2:
gestures.append(gesture_touches)
gesture_touches = [result]
if len(gesture_touches) == 2:
gestures.append(gesture_touches)
return list(map(partial(cls.gesture_factory, **kwargs), gestures))
@property
def pinching_duration(self) -> datetime.timedelta:
"""Get the duration of the pinching.
Returns
-------
datetime.timedelta
Returns the duration of the pinching if the gesture is considered
a valid pinch based on ``pinch_begin`` and ``pinch_end``. If the
pinch is not valid according to the app (``isValidPinch`` in the
``screen`` data set is ``false``) it returns ``None``.
"""
return self.pinch_end - self.pinch_begin
@property
def first_push_top_fingers(self) -> float:
"""Get the first pressure measurement of the top finger.
Returns
-------
float
The first pressure reading from the top finger that was non-zero.
"""
return self.top_finger.initial_pressure
@property
def first_push_bottom_fingers(self) -> float:
"""Get the first pressure measurement of the bottom finger.
Returns
-------
float
The first pressure reading from the bottom finger that was
non-zero.
"""
return self.bottom_finger.initial_pressure
@property
def double_touch_asynchrony(self) -> datetime.timedelta:
"""Get the double touch asynchrony of the pinch attempt.
Returns
-------
datetime.timedelta
The time difference between the first and second finger touching
the screen for a pinch attempt.
"""
return abs(self.top_finger.begin - self.bottom_finger.begin)
[docs]
class PinchTarget:
"""A pinch target class.
This encapsulates pinch targets for each level e.g. `'right-small'`,
`'right-small-01'` etc.
Attributes
----------
id: str
The pinch target identifier e.g. `'right-small-01'`.
parent_id: str
The pinch target parent identifier e.g. `'right-small'`.
hand: HandModality
The hand used for the pinch.
size: BubbleSizeModality
The target bubble size.
radius: float
The radius of the pinch target.
coordinates: Tuple[float, float]
The coordinates of the pinch target.
appearance: pandas.Timestamp
The timestamp at which the target initially appeared.
attempts: List[PinchAttempt]
A list of the pinch attempts for the current target.
"""
[docs]
def __init__(self):
self.id: str = field(init=False)
self.parent_id: str = field(init=False)
self.hand: HandModality = field(init=False)
self.size: BubbleSizeModality = field(init=False)
self.radius: float = field(init=False)
self.coordinates: Tuple[float, float] = field(init=False)
self.appearance: datetime = field(init=False)
self.attempts: List[PinchAttempt] = []
def __repr__(self):
return (
f"<Pinch Target: {self.id}, " f'{plural("attempt", len(self.attempts))}.>'
)
[docs]
@classmethod
def from_level(cls, level: Level) -> Optional["PinchTarget"]:
"""Initialize pinch target from level.
Parameters
----------
level
The level from which the pinch target is to be initialized.
Returns
-------
PinchTarget
The pinch target.
Raises
------
ValueError
If the PinchTarget id doesn't start with a `hand-size` format
"""
target = cls()
parent_id = _remove_attempt(id_ := str(level.id))
try:
components = parent_id.split("-")
hand = HandModality.from_variable(components[0])
size = BubbleSizeModality.from_variable(components[1])
except Exception as error:
raise ValueError(
"PinchTarget id must start with a `hand-size` format."
) from error
target.id = id_
target.parent_id = parent_id
target.hand = hand
target.size = size
context = level.context
target.radius = context.get_raw_value("targetRadius")
target.coordinates = (
context.get_raw_value("xTargetBall"),
context.get_raw_value("yTargetBall"),
)
target.appearance = level.start
data = level.get_raw_data_set("screen").data
if not data.empty:
attempts: List[PinchAttempt] = []
try:
attempts = cast(
List[PinchAttempt],
PinchAttempt.from_data_frame(data, context=context),
)
except AssertionError:
pass
for attempt in filter(lambda a: a.is_valid, attempts):
target.attempts.append(attempt)
return target
@property
def has_attempts(self) -> bool:
"""Get whether the pinch target has attempts."""
return len(self.attempts) > 0
@property
def first_attempt(self) -> PinchAttempt:
"""Get the first pinch attempt."""
return min(self.attempts, key=lambda a: a.begin)
[docs]
def get_attempts_from(self, modality: AttemptOutcomeModality) -> List[PinchAttempt]:
"""Get the list of attempts from success pinche modality."""
if modality == AttemptOutcomeModality.ALL:
return self.attempts
return [a for a in self.attempts if a.is_successful == modality.is_success]
@property
def total_duration(self) -> datetime.timedelta:
"""Get the total duration of the pinch attempts."""
try:
return self.attempts[-1].end - self.first_attempt.begin
except IndexError:
return datetime.timedelta(0)
@property
def reaction_time(self) -> pd.Timedelta:
"""Get the user reaction time."""
try:
return self.attempts[0].begin - self.appearance
except IndexError:
return pd.Timedelta("nan")
[docs]
def first_pushes(
self,
finger: FingerModality,
outcome: AttemptOutcomeModality = AttemptOutcomeModality.ALL,
) -> List[float]:
"""Get the first pushes of a pinch target's fingers attempts."""
if finger == FingerModality.TOP_FINGER:
return list(
map(lambda a: a.first_push_top_fingers, self.get_attempts_from(outcome))
)
return list(
map(lambda a: a.first_push_bottom_fingers, self.get_attempts_from(outcome))
)
[docs]
def total_number_pinches(target: PinchTarget) -> int:
"""Return total number of pinches.
A pinch is defined as a potential pinch with at least one touch event
marked with isValidPinch.
Parameters
----------
target
A pinch target object.
Returns
-------
int
The total number of pinches.
"""
return len(target.attempts)
[docs]
def number_successful_pinches(target: PinchTarget) -> int:
"""Return number of successful pinches.
A pinch is defined as a potential pinch with at least one touch event
marked with ledToSuccess.
Parameters
----------
target
A pinch target object.
Returns
-------
int
Number of successful pinches
"""
return sum(attempt.is_successful for attempt in target.attempts)
[docs]
def success_duration(
target: PinchTarget, modality: AttemptOutcomeModality = AttemptOutcomeModality.ALL
) -> List[float]:
"""Compute the success duration.
The success duration i.e. the duration spent before succeeding at
pinching a bubble.
Parameters
----------
target
A pinch target object.
modality
Pinching attempt success modality.
Returns
-------
List[float]
A list regrouping the computed success durations in s.
"""
return [
(attempt.pinch_begin - attempt.begin).total_seconds()
for attempt in filter(
lambda x: x.pinch_begin is not None, target.get_attempts_from(modality)
)
]
[docs]
def pinching_duration(
target: PinchTarget, modality: AttemptOutcomeModality = AttemptOutcomeModality.ALL
) -> List[float]:
"""Compute the pinching duration.
The pinching duration i.e. the duration spent actually deforming the
bubble.
Parameters
----------
target
A pinch target object.
modality
Pinching attempt success modality.
Returns
-------
List[float]
A list regrouping the computed pinching durations in s.
"""
return [
attempt.pinching_duration.total_seconds()
for attempt in filter(
lambda x: x.pinching_duration is not None,
target.get_attempts_from(modality),
)
]
[docs]
def total_duration(target: PinchTarget) -> float:
"""Compute the total duration of the pinch attempts.
Parameters
----------
target
A pinch target object.
Returns
-------
float
The computed total duration in s.
"""
return target.total_duration.total_seconds()
[docs]
def reaction_time(target: PinchTarget) -> float:
"""Compute the reaction time.
The reaction time of the user between the bubble appearance and the first
touch event.
Parameters
----------
target
A pinch target object.
Returns
-------
float
The user reaction time in ms.
"""
return target.reaction_time.total_seconds() * 1e3
[docs]
def dwell_time(
target: PinchTarget, attempt: AttemptSelectionModality
) -> Union[float, List[float]]:
"""Compute the pinching attempts' dwell times.
The dwell time i.e. time spent between the first screen touching and the
initiation of the movement.
Parameters
----------
target
A pinch target object.
attempt
Pinching attempt selection modality.
Returns
-------
List[float]
A list regrouping the computed dwell times in ms.
"""
if attempt.is_first:
if target.has_attempts:
return target.first_attempt.dwell_time.total_seconds() * 1e3
return np.nan
return [attempt.dwell_time.total_seconds() * 1e3 for attempt in target.attempts]
[docs]
def double_touch_asynchrony(
target: PinchTarget, attempt: AttemptSelectionModality
) -> Union[float, List[float]]:
"""Compute the pinching attempts' double touch asynchrony.
The double touch asynchrony i.e. time difference between the first and
second finger touching the screen for all pinch attempts.
Parameters
----------
target
A pinch target object.
attempt
Pinching attempt selection modality.
Returns
-------
List[float]
A list regrouping the computed double touch asynchrony in ms.
"""
if attempt.is_first:
if target.has_attempts:
dat = target.first_attempt.double_touch_asynchrony
return dat.total_seconds() * 1e3
return np.nan
return [
attempt.double_touch_asynchrony.total_seconds() * 1e3
for attempt in target.attempts
]