Source code for dispel.signal.orientation
"""A module to detect and process phone orientation during tests."""
import math
from collections import namedtuple
from operator import ge, le
from typing import Callable, Iterable, Union
import pandas as pd
from dispel.data.values import AVEnum
#: tuple of phone range orientation in degree
OrientationRange = namedtuple("OrientationRange", ["lower", "upper"])
[docs]
class PhoneOrientation(AVEnum):
"""A class enumerator regrouping supported phone orientations."""
LANDSCAPE_RIGHT = ("landscape right mode", "lrm")
LANDSCAPE_LEFT = ("landscape left mode", "llm")
PORTRAIT_UPRIGHT = ("portrait upright mode", "upm")
PORTRAIT_UPSIDE_DOWN = ("portrait upside down mode", "udpm")
FACE_UP = ("face up mode", "fum")
FACE_DOWN = ("face down mode", "fdm")
@property
def is_portrait(self) -> bool:
"""Return whether the orientation is in portrait mode."""
return self in (self.PORTRAIT_UPRIGHT, self.PORTRAIT_UPSIDE_DOWN)
@property
def is_landscape(self) -> bool:
"""Return whether the orientation is in landscape mode."""
return self in (self.LANDSCAPE_RIGHT, self.LANDSCAPE_LEFT)
@property
def is_flat(self) -> bool:
"""Return whether the orientation is in a flat mode."""
return self in (self.FACE_UP, self.FACE_DOWN)
@property
def is_up(self) -> bool:
"""Return whether the orientation is upright."""
return self in (self.LANDSCAPE_RIGHT, self.PORTRAIT_UPRIGHT, self.FACE_UP)
@property
def is_down(self) -> bool:
"""Return whether the orientation is upside down."""
return self in (self.LANDSCAPE_LEFT, self.PORTRAIT_UPSIDE_DOWN, self.FACE_DOWN)
@property
def frontal_axis(self) -> str:
"""Get the name of the axis facing the observer."""
if self.is_landscape or self.is_portrait:
return "gravityZ"
if self.is_flat:
return "gravityX"
raise ValueError("Unsupported phone orientation.")
@property
def top_axis(self) -> str:
"""Get the name of the axis perpendicular to the ground."""
if self.is_landscape:
return "gravityX"
if self.is_portrait:
return "gravityY"
if self.is_flat:
return "gravityZ"
raise ValueError("Unsupported phone orientation.")
@property
def side_axis(self) -> str:
"""Get the name of the axis on the side of the phone."""
if self.is_landscape or self.is_flat:
return "gravityY"
if self.is_portrait:
return "gravityX"
raise ValueError("Unsupported phone orientation.")
def _get_top_axis_threshold(self, pitch_angle: float, yaw_angle: float) -> float:
"""Get the threshold for the top axis."""
angle = min(pitch_angle, yaw_angle) if self.is_flat else pitch_angle
return math.cos(angle)
def _get_top_axis_sign(self) -> int:
"""Get the sign of the top axis."""
if self.is_up:
return -1
if self.is_down:
return 1
raise ValueError("Phone orientation can only be facing up or down.")
[docs]
def is_top_axis_valid(
self, top_axis_gravity: float, pitch_angle: float, yaw_angle: float
) -> bool:
"""Check whether the condition on the top axis is verified."""
threshold = self._get_top_axis_threshold(pitch_angle, yaw_angle)
operator = ge if (sign := self._get_top_axis_sign()) > 0 else le
return operator(top_axis_gravity, sign * threshold)
[docs]
def get_classifier(
self,
pitch_freedom: Union[float, OrientationRange],
yaw_freedom: Union[float, OrientationRange],
) -> Callable[[pd.DataFrame], pd.Series]:
"""Get the phone orientation classifier.
Parameters
----------
pitch_freedom
The degree of freedom of the pitch angle in degrees.
yaw_freedom
The degree of freedom of the yaw angle in degrees.
Returns
-------
Callable[[pandas.DataFrame], pandas.Series]
A function that takes as input the gravity data frame and output a pandas
series of boolean corresponding to the phone orientation classification.
"""
def _unitary_classifier(row: pd.Series) -> bool:
if not isinstance(pitch_freedom, OrientationRange):
pitch_range = OrientationRange(
lower=-pitch_freedom / 2, upper=pitch_freedom / 2
)
else:
pitch_range = pitch_freedom
if not isinstance(yaw_freedom, OrientationRange):
yaw_range = OrientationRange(
lower=-yaw_freedom / 2, upper=yaw_freedom / 2
)
else:
yaw_range = yaw_freedom
pitch_angle_low = math.radians(pitch_range.lower)
pitch_angle_high = math.radians(pitch_range.upper)
yaw_angle_low = math.radians(yaw_range.lower)
yaw_angle_high = math.radians(yaw_range.upper)
return (
self.is_top_axis_valid(
row[self.top_axis], pitch_angle_low, yaw_angle_low
)
and math.sin(pitch_angle_low)
<= row[self.frontal_axis]
<= math.sin(pitch_angle_high)
and math.sin(yaw_angle_low)
<= row[self.side_axis]
<= math.sin(yaw_angle_high)
)
def _classifier(data: pd.DataFrame) -> pd.Series:
expected_columns = {f"gravity{axis}" for axis in "XYZ"}
assert expected_columns <= (
columns := set(data.columns)
), f"Missing gravity columns {expected_columns - columns}"
return data.apply(_unitary_classifier, axis=1)
return _classifier
PhoneOrientationType = Union[PhoneOrientation, Iterable[PhoneOrientation]]