Repository: aeye-lab/pymovements
Pull request #266 (2023-03-09 21:14:11)

Events: Add disposition and amplitude as event properties
## Description

This closes issue #231

Add new event properties: `disposition()` and `amplitude()`

Followup to PR #262

Commit https://github.com/aeye-lab/pymovements/pull/266/commits/76544595c04de0868aca32d24e5153599d2dca3a is the actual change in this PR. the first two commits are from PR #262

## Implemented changes

- [x] Add `events.event_properties.disposition()`
- [x] Add `events.event_properties.amplitude()`

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change is or requires a documentation update

## How Has This Been Tested?

- [x] Added a tests for single, two and three samples
- [x] Added test for exceptions

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


codecov-commenter: ## [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`100.00`**% and project coverage change: **`+0.33`** :tada:
> Comparison is base [(`a82d4b5`)](https://codecov.io/gh/aeye-lab/pymovements/commit/a82d4b5e1e190b411a0a554ee8a0fbe84180d4ce?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 98.05% compared to head [(`7654459`)](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 98.39%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #266      +/-   ##
==========================================
+ Coverage   98.05%   98.39%   +0.33%     
==========================================
  Files          28       32       +4     
  Lines        1081     1310     +229     
  Branches      248      307      +59     
==========================================
+ Hits         1060     1289     +229     
  Misses         19       19              
  Partials        2        2              
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/exceptions.py](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V4Y2VwdGlvbnMucHk=) | `100.00% <ø> (ø)` | |
| [src/pymovements/datasets/dataset.py](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2RhdGFzZXQucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/event\_processing.py](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9ldmVudF9wcm9jZXNzaW5nLnB5) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/event\_properties.py](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9ldmVudF9wcm9wZXJ0aWVzLnB5) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/events.py](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9ldmVudHMucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/gaze\_dataframe.py](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZ2F6ZV9kYXRhZnJhbWUucHk=) | `100.00% <100.00%> (ø)` | |

... and [3 files with indirect coverage changes](https://codecov.io/gh/aeye-lab/pymovements/pull/266/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/aeye-lab/pymovements/pull/266?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

SiQube: wrt reviewing -- @dkrako please ping me after resolving the conflicts
dkrako: > wrt reviewing -- @dkrako please ping me after resolving the conflicts

@SiQube resolved

Patch:
diff --git a/src/pymovements/events/event_properties.py b/src/pymovements/events/event_properties.py
index ddee9ae..ac2526b 100644
--- a/src/pymovements/events/event_properties.py
+++ b/src/pymovements/events/event_properties.py
@@ -58,25 +58,12 @@ def peak_velocity(velocity_columns: tuple[str, str] = ('x_vel', 'y_vel')) -> pl.
         If velocity_columns not of type tuple, velocity_columns not of length 2, or elements of
         velocity_columns not of type str.
     """
-    if not isinstance(velocity_columns, tuple):
-        raise TypeError(
-            'velocity_columns must be of type tuple[str, str]'
-            f' but is of type {type(velocity_columns).__name__}',
-        )
-    if len(velocity_columns) != 2:
-        raise TypeError(
-            f'velocity_columns must be of length of 2 but is of length {len(velocity_columns)}',
-        )
-    if not all(isinstance(velocity_column, str) for velocity_column in velocity_columns):
-        raise TypeError(
-            'velocity_columns must be of type tuple[str, str] but is '
-            f'tuple[{type(velocity_columns[0]).__name__}, {type(velocity_columns[1]).__name__}]',
-        )
+    _check_velocity_columns(velocity_columns)
 
-    x_velocity = velocity_columns[0]
-    y_velocity = velocity_columns[1]
+    x_velocity = pl.col(velocity_columns[0])
+    y_velocity = pl.col(velocity_columns[1])
 
-    return (pl.col(x_velocity).pow(2) + pl.col(y_velocity).pow(2)).sqrt().max()
+    return (x_velocity.pow(2) + y_velocity.pow(2)).sqrt().max()
 
 
 @register_event_property
@@ -94,6 +81,68 @@ def dispersion(position_columns: tuple[str, str] = ('x_pos', 'y_pos')) -> pl.Exp
         If position_columns not of type tuple, position_columns not of length 2, or elements of
         position_columns not of type str.
     """
+    _check_position_columns(position_columns)
+
+    x_position = pl.col(position_columns[0])
+    y_position = pl.col(position_columns[1])
+
+    return x_position.max() - x_position.min() + y_position.max() - y_position.min()
+
+
+@register_event_property
+def amplitude(position_columns: tuple[str, str] = ('x_pos', 'y_pos')) -> pl.Expr:
+    """Amplitude of an event.
+
+    Parameters
+    ----------
+    position_columns
+        The column names of the pitch and yaw position components.
+
+    Raises
+    ------
+    TypeError
+        If position_columns not of type tuple, position_columns not of length 2, or elements of
+        position_columns not of type str.
+    """
+    _check_position_columns(position_columns)
+
+    x_position = pl.col(position_columns[0])
+    y_position = pl.col(position_columns[1])
+
+    return (
+        (x_position.max() - x_position.min()).pow(2)
+        + (y_position.max() - y_position.min()).pow(2)
+    ).sqrt()
+
+
+@register_event_property
+def disposition(position_columns: tuple[str, str] = ('x_pos', 'y_pos')) -> pl.Expr:
+    """Disposition of an event.
+
+    Parameters
+    ----------
+    position_columns
+        The column names of the pitch and yaw position components.
+
+    Raises
+    ------
+    TypeError
+        If position_columns not of type tuple, position_columns not of length 2, or elements of
+        position_columns not of type str.
+    """
+    _check_position_columns(position_columns)
+
+    x_position = pl.col(position_columns[0])
+    y_position = pl.col(position_columns[1])
+
+    return (
+        (x_position.head(n=1) - x_position.reverse().head(n=1)).pow(2)
+        + (y_position.head(n=1) - y_position.reverse().head(n=1)).pow(2)
+    ).sqrt()
+
+
+def _check_position_columns(position_columns: tuple[str, str]) -> None:
+    """Check if position_columns is of type tuple[str, str]."""
     if not isinstance(position_columns, tuple):
         raise TypeError(
             'position_columns must be of type tuple[str, str]'
@@ -109,10 +158,20 @@ def dispersion(position_columns: tuple[str, str] = ('x_pos', 'y_pos')) -> pl.Exp
             f'tuple[{type(position_columns[0]).__name__}, {type(position_columns[1]).__name__}]',
         )
 
-    x_position = position_columns[0]
-    y_position = position_columns[1]
 
-    return (
-        (pl.col(x_position).max() - pl.col(x_position).min())
-        + (pl.col(y_position).max() - pl.col(y_position).min())
-    )
+def _check_velocity_columns(velocity_columns: tuple[str, str]) -> None:
+    """Check if velocity_columns is of type tuple[str, str]."""
+    if not isinstance(velocity_columns, tuple):
+        raise TypeError(
+            'velocity_columns must be of type tuple[str, str]'
+            f' but is of type {type(velocity_columns).__name__}',
+        )
+    if len(velocity_columns) != 2:
+        raise TypeError(
+            f'velocity_columns must be of length of 2 but is of length {len(velocity_columns)}',
+        )
+    if not all(isinstance(velocity_column, str) for velocity_column in velocity_columns):
+        raise TypeError(
+            'velocity_columns must be of type tuple[str, str] but is '
+            f'tuple[{type(velocity_columns[0]).__name__}, {type(velocity_columns[1]).__name__}]',
+        )


Test patch:
diff --git a/tests/events/event_properties_test.py b/tests/events/event_properties_test.py
index 0b70b2c..6b65f7a 100644
--- a/tests/events/event_properties_test.py
+++ b/tests/events/event_properties_test.py
@@ -18,6 +18,7 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """Test module pymovements.events.event_properties"""
+import numpy as np
 import polars as pl
 import pytest
 from polars.testing import assert_frame_equal
@@ -85,6 +86,27 @@ from pymovements.events.event_properties import EVENT_PROPERTIES
             ('position_columns', 'type', 'must', 'tuple', 'str', 'int'),
             id='duration_tuple_of_int',
         ),
+        pytest.param(
+            event_properties.amplitude,
+            {'position_columns': 1},
+            TypeError,
+            ('position_columns', 'type', 'must', 'tuple', 'int'),
+            id='amplitude_int',
+        ),
+        pytest.param(
+            event_properties.amplitude,
+            {'position_columns': ('x_pos', 'y_pos', 'z_pos')},
+            TypeError,
+            ('position_columns', 'length', 'must', '2', '3'),
+            id='amplitude_tuple_length_3',
+        ),
+        pytest.param(
+            event_properties.amplitude,
+            {'position_columns': ('x_pos', 1)},
+            TypeError,
+            ('position_columns', 'type', 'must', 'tuple', 'str', 'int'),
+            id='amplitude_tuple_of_int',
+        ),
     ],
 )
 def test_property_init_exceptions(event_property, init_kwargs, exception, msg_substrings):
@@ -115,21 +137,53 @@ def test_property_init_exceptions(event_property, init_kwargs, exception, msg_su
             ('onset',),
             id='duration_missing_onset_column',
         ),
+        pytest.param(
+            event_properties.peak_velocity,
+            {'velocity_columns': ('x_vel', 'y_vel')},
+            pl.DataFrame(schema={'y_vel': pl.Int64}),
+            pl.exceptions.ColumnNotFoundError,
+            ('x_vel',),
+            id='peak_velocity_missing_x_vel_column',
+        ),
         pytest.param(
             event_properties.peak_velocity,
             {'velocity_columns': ('x_vel', 'y_vel')},
             pl.DataFrame(schema={'x_vel': pl.Int64}),
             pl.exceptions.ColumnNotFoundError,
             ('y_vel',),
-            id='peak_velocity_missing_velocity_column',
+            id='peak_velocity_missing_y_vel_column',
         ),
         pytest.param(
             event_properties.dispersion,
             {'position_columns': ('x_pos', 'y_pos')},
+            pl.DataFrame(schema={'y_pos': pl.Int64}),
+            pl.exceptions.ColumnNotFoundError,
+            ('x_pos',),
+            id='dispersion_missing_x_pos_column',
+        ),
+        pytest.param(
+            event_properties.dispersion,
+            {'position_columns': ('x_pos', 'y_pos')},
+            pl.DataFrame(schema={'x_pos': pl.Int64}),
+            pl.exceptions.ColumnNotFoundError,
+            ('y_pos',),
+            id='dispersion_missing_y_pos_column',
+        ),
+        pytest.param(
+            event_properties.amplitude,
+            {'position_columns': ('x_pos', 'y_pos')},
+            pl.DataFrame(schema={'y_pos': pl.Int64}),
+            pl.exceptions.ColumnNotFoundError,
+            ('x_pos',),
+            id='amplitude_missing_x_pos_column',
+        ),
+        pytest.param(
+            event_properties.amplitude,
+            {'position_columns': ('x_pos', 'y_pos')},
             pl.DataFrame(schema={'x_pos': pl.Int64}),
             pl.exceptions.ColumnNotFoundError,
             ('y_pos',),
-            id='dispersion_missing_position_column',
+            id='amplitude_missing_y_pos_column',
         ),
     ],
 )
@@ -153,6 +207,7 @@ def test_property_exceptions(event_property, init_kwargs, input_df, exception, m
             pl.DataFrame(schema={'duration': pl.Int64}),
             id='empty_dataframe_results_in_empty_dataframe_with_correct_schema',
         ),
+
         pytest.param(
             event_properties.duration,
             {},
@@ -160,6 +215,7 @@ def test_property_exceptions(event_property, init_kwargs, input_df, exception, m
             pl.DataFrame({'duration': 1}, schema={'duration': pl.Int64}),
             id='single_event_duration',
         ),
+
         pytest.param(
             event_properties.duration,
             {},
@@ -173,6 +229,7 @@ def test_property_exceptions(event_property, init_kwargs, input_df, exception, m
             ),
             id='two_events_different_durations',
         ),
+
         pytest.param(
             event_properties.peak_velocity,
             {},
@@ -186,6 +243,49 @@ def test_property_exceptions(event_property, init_kwargs, input_df, exception, m
             ),
             id='single_event_peak_velocity',
         ),
+
+        pytest.param(
+            event_properties.dispersion,
+            {},
+            pl.DataFrame(
+                {'x_pos': [2], 'y_pos': [3]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'dispersion': [0]},
+                schema={'dispersion': pl.Float64},
+            ),
+            id='dispersion_one_sample',
+        ),
+
+        pytest.param(
+            event_properties.dispersion,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 2], 'y_pos': [0, 0]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'dispersion': [2]},
+                schema={'dispersion': pl.Float64},
+            ),
+            id='dispersion_two_samples_x_move',
+        ),
+
+        pytest.param(
+            event_properties.dispersion,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 0], 'y_pos': [0, 3]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'dispersion': [3]},
+                schema={'dispersion': pl.Float64},
+            ),
+            id='dispersion_two_samples_y_move',
+        ),
+
         pytest.param(
             event_properties.dispersion,
             {},
@@ -197,7 +297,147 @@ def test_property_exceptions(event_property, init_kwargs, input_df, exception, m
                 {'dispersion': [5]},
                 schema={'dispersion': pl.Float64},
             ),
-            id='single_event_dispersion',
+            id='dispersion_two_samples_xy_move',
+        ),
+
+        pytest.param(
+            event_properties.disposition,
+            {},
+            pl.DataFrame(
+                {'x_pos': [1], 'y_pos': [1]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'disposition': [0]},
+                schema={'disposition': pl.Float64},
+            ),
+            id='disposition_single_sample',
+        ),
+
+        pytest.param(
+            event_properties.disposition,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 1], 'y_pos': [0, 0]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'disposition': [1]},
+                schema={'disposition': pl.Float64},
+            ),
+            id='disposition_two_samples_x_move',
+        ),
+
+        pytest.param(
+            event_properties.disposition,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 0], 'y_pos': [0, 1]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'disposition': [1]},
+                schema={'disposition': pl.Float64},
+            ),
+            id='disposition_two_samples_y_move',
+        ),
+
+        pytest.param(
+            event_properties.disposition,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 1], 'y_pos': [0, 1]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'disposition': [np.sqrt(2)]},
+                schema={'disposition': pl.Float64},
+            ),
+            id='disposition_two_samples_xy_move',
+        ),
+
+        pytest.param(
+            event_properties.disposition,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 1.1, 1], 'y_pos': [0, 0, 0]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'disposition': [1]},
+                schema={'disposition': pl.Float64},
+            ),
+            id='disposition_three_samples_overshoot',
+        ),
+
+        pytest.param(
+            event_properties.disposition,
+            {},
+            pl.DataFrame(
+                {'x_pos': [-1, 0, 1], 'y_pos': [0, 0, 0]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'disposition': [2]},
+                schema={'disposition': pl.Float64},
+            ),
+            id='disposition_three_samples_negative',
+        ),
+
+        pytest.param(
+            event_properties.amplitude,
+            {},
+            pl.DataFrame(
+                {'x_pos': [4], 'y_pos': [5]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'amplitude': [0]},
+                schema={'amplitude': pl.Float64},
+            ),
+            id='amplitude_one_sample',
+        ),
+
+        pytest.param(
+            event_properties.amplitude,
+            {},
+            pl.DataFrame(
+                {'x_pos': [2, 0], 'y_pos': [0, 0]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'amplitude': [2]},
+                schema={'amplitude': pl.Float64},
+            ),
+            id='amplitude_two_samples_x_move',
+        ),
+
+        pytest.param(
+            event_properties.amplitude,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 0], 'y_pos': [3, 0]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'amplitude': [3]},
+                schema={'amplitude': pl.Float64},
+            ),
+            id='amplitude_two_samples_y_move',
+        ),
+
+        pytest.param(
+            event_properties.amplitude,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 1], 'y_pos': [0, 1]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'amplitude': [np.sqrt(2)]},
+                schema={'amplitude': pl.Float64},
+            ),
+            id='amplitude_two_samples_xy_move',
         ),
     ],
 )
@@ -212,8 +452,17 @@ def test_property_has_expected_result(event_property, init_kwargs, input_df, exp
     [
         pytest.param(event_properties.duration, 'duration', id='duration'),
         pytest.param(event_properties.peak_velocity, 'peak_velocity', id='peak_velocity'),
+        pytest.param(event_properties.dispersion, 'dispersion', id='dispersion'),
+        pytest.param(event_properties.amplitude, 'amplitude', id='amplitude'),
+        pytest.param(event_properties.disposition, 'disposition', id='disposition'),
     ],
 )
 def test_property_registered(property_function, property_function_name):
     assert property_function_name in EVENT_PROPERTIES
     assert EVENT_PROPERTIES[property_function_name] == property_function
+    assert EVENT_PROPERTIES[property_function_name].__name__ == property_function_name
+
+
+@pytest.mark.parametrize('property_function', EVENT_PROPERTIES.values())
+def test_property_returns_polars_expression(property_function):
+    assert isinstance(property_function(), pl.Expr)