Repository: aeye-lab/pymovements
Pull request #421 (2023-05-25 13:54:25)

feat(events): Add position event property
## Description

`position()` will compute a series with type `pl.List(pl.Float64)` which will hold the centroid positions of each event sequence.

This also replaces and removes the implementation of `centroid_fixation()`

The `EventGazeProcessor` has increased in complexity but we will get rid of a some stuff again when all positions and velocities are treated as tuples by default.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] This change is or requires a documentation update

## How Has This Been Tested?

I have transformed all existing tests from testing `centroid_fixation` to testing `position`
- [x] adjust tests for unsupported method raises value error
- [x] adjust tests correct calculations
- [x] add test cases when testing `Dataset.compute_event_properties()`

## 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[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/421?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 no project coverage change.
> Comparison is base [(`27588be`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/27588bee3449bd036cdc8baf4396a61a6ddc9a71?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`ef237d6`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/421?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00%.

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


```diff
@@            Coverage Diff            @@
##              main      #421   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         1910      1916    +6     
  Branches       424       427    +3     
=========================================
+ Hits          1910      1916    +6     
```


| [Impacted Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/421?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/events/\_\_init\_\_.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/421?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9fX2luaXRfXy5weQ==) | `100.00% <ø> (ø)` | |
| [src/pymovements/events/event\_processing.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/421?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://app.codecov.io/gh/aeye-lab/pymovements/pull/421?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%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/421?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).


Patch:
diff --git a/src/pymovements/events/__init__.py b/src/pymovements/events/__init__.py
index 026447a..9151a9d 100644
--- a/src/pymovements/events/__init__.py
+++ b/src/pymovements/events/__init__.py
@@ -53,11 +53,12 @@
     :toctree:
     :recursive:
 
+    pymovements.events.event_properties.amplitude
     pymovements.events.event_properties.duration
     pymovements.events.event_properties.dispersion
     pymovements.events.event_properties.disposition
-    pymovements.events.event_properties.amplitude
     pymovements.events.event_properties.peak_velocity
+    pymovements.events.event_properties.position
 """
 from pymovements.events.detection.idt import idt
 from pymovements.events.detection.ivt import ivt
diff --git a/src/pymovements/events/event_processing.py b/src/pymovements/events/event_processing.py
index 544fd70..addc2e7 100644
--- a/src/pymovements/events/event_processing.py
+++ b/src/pymovements/events/event_processing.py
@@ -177,6 +177,18 @@ class EventGazeProcessor:
                 position_columns = tuple(gaze.position_columns[:2])
                 property_kwargs[property_name]['position_columns'] = position_columns
 
+            if 'position_column' in property_args:
+                # We need to create a new column here, which is a list of position tuples.
+                # For the intermediate time before the tuple will be the default format for
+                # positions, we create this column here and drop this column afterwards.
+                position_columns = tuple(gaze.position_columns[:2])
+                component_expressions = [pl.col(component) for component in position_columns]
+                gaze.frame = gaze.frame.with_columns(
+                    pl.concat_list(component_expressions)
+                    .alias('position'),
+                )
+                property_kwargs[property_name]['position_column'] = 'position'
+
         result = (
             gaze.frame.join(events.frame, on=identifiers)
             .filter(pl.col('time').is_between(pl.col('onset'), pl.col('offset')))
@@ -186,7 +198,20 @@ class EventGazeProcessor:
                     property_expression(**property_kwargs[property_name])
                     .alias(property_name)
                     for property_name, property_expression in property_expressions.items()
+                    if 'position_column' not in property_kwargs[property_name]
+                ] + [
+                    property_expression(**property_kwargs[property_name])
+                    .alias(property_name)
+                    .first()  # Not sure why this is needed, an outer list is being created somehow.
+                    for property_name, property_expression in property_expressions.items()
+                    if 'position_column' in property_kwargs[property_name]
+
                 ],
             )
         )
+
+        # If we created the position tuple column we drop it again.
+        if 'position' in gaze.frame.columns:
+            gaze.frame.drop_in_place('position')
+
         return result
diff --git a/src/pymovements/events/event_properties.py b/src/pymovements/events/event_properties.py
index f8d8b12..22c6e6b 100644
--- a/src/pymovements/events/event_properties.py
+++ b/src/pymovements/events/event_properties.py
@@ -142,62 +142,51 @@ def disposition(position_columns: tuple[str, str] = ('x_pos', 'y_pos')) -> pl.Ex
 
 
 @register_event_property
-def fixation_centroid(
-        component: str = 'x',
+def position(
         method: str = 'mean',
-        position_columns: tuple[str, str] = ('x_pos', 'y_pos'),
+        position_column: str = 'position',
+        n_components: int = 2,
 ) -> pl.Expr:
-    """Calculate a fixations x centroid.
+    """Centroid position of an event.
 
     Parameters
     ----------
-    component
-        Specify the component of which to calculate the  centroid.
-        Supported components: 'x', 'y'.
-        Defaults to 'x'.
     method
-        Specify the type of centroid to be extracted, supported methods: 'mean', 'median'.
+        The centroid method to be used for calculation. Supported methods are ``mean``, ``median``.
         Defaults to 'mean'.
-    position_columns
-        The column names of the pitch and yaw position components.
+    position_column
+        The column name of the position tuples.
+    n_components:
+        Number of positional components. Usually these are the two components yaw and pitch.
 
     Raises
     ------
-    TypeError
-        If position_columns not of type tuple, position_columns not of length 2, or elements of
-        position_columns not of type str.
-    ValueError
-        If component is not one of the supported methods.
     ValueError
         If method is not one of the supported methods.
     """
-    _check_position_columns(position_columns)
-
-    component_to_pos = {
-        'x': 0,
-        'y': 1,
-    }
-
-    if component not in component_to_pos:
-        raise ValueError(
-            f'Component {component} not supported. '
-            f"Please choose one of the following: ['x', 'y'].",
-        )
-
-    position = pl.col(position_columns[component_to_pos[component]])
     if method not in ['mean', 'median']:
         raise ValueError(
-            f'Method {method} not supported. '
+            f"Method '{method}' not supported. "
             f"Please choose one of the following: ['mean', 'median'].",
         )
 
-    if method == 'mean':
-        centroid = position.mean()
+    component_expressions = []
+    for component in range(n_components):
+        position_component = (
+            pl.col(position_column)
+            .arr.slice(0, None)
+            .arr.get(component)
+        )
+
+        if method == 'mean':
+            expression_component = position_component.mean()
+
+        if method == 'median':
+            expression_component = position_component.median()
 
-    if method == 'median':
-        centroid = position.median()
+        component_expressions.append(expression_component)
 
-    return centroid
+    return pl.concat_list(component_expressions)
 
 
 def _check_position_columns(position_columns: tuple[str, str]) -> None:


Test patch:
diff --git a/tests/dataset/dataset_test.py b/tests/dataset/dataset_test.py
index dd251c7..f40899b 100644
--- a/tests/dataset/dataset_test.py
+++ b/tests/dataset/dataset_test.py
@@ -1187,6 +1187,16 @@ def test_event_dataframe_add_property_has_expected_height(dataset_configuration,
             },
             id='single_event_peak_velocity',
         ),
+        pytest.param(
+            {'event_properties': 'position'},
+            {
+                'subject_id': pl.Int64,
+                **pm.events.EventDataFrame._minimal_schema,
+                'duration': pl.Int64,
+                'position': pl.List(pl.Float64),
+            },
+            id='single_event_position',
+        ),
     ],
 )
 def test_event_dataframe_add_property_has_expected_schema(
@@ -1198,6 +1208,8 @@ def test_event_dataframe_add_property_has_expected_schema(
     dataset.compute_event_properties(**property_kwargs)
 
     for events_df in dataset.events:
+        if events_df.frame.schema != expected_schema:
+            print(events_df.frame)
         assert events_df.frame.schema == expected_schema
 
 
@@ -1209,6 +1221,11 @@ def test_event_dataframe_add_property_has_expected_schema(
             ['peak_velocity'],
             id='single_event_peak_velocity',
         ),
+        pytest.param(
+            {'event_properties': 'position'},
+            ['position'],
+            id='single_event_position',
+        ),
     ],
 )
 def test_event_dataframe_add_property_effect_property_columns(
diff --git a/tests/events/event_properties_test.py b/tests/events/event_properties_test.py
index dad58b9..d78e823 100644
--- a/tests/events/event_properties_test.py
+++ b/tests/events/event_properties_test.py
@@ -108,18 +108,11 @@ from pymovements.events.event_properties import EVENT_PROPERTIES
             id='amplitude_tuple_of_int',
         ),
         pytest.param(
-            event_properties.fixation_centroid,
+            event_properties.position,
             {'method': 'foo'},
             ValueError,
             ('method', 'foo', 'not', 'supported', 'mean', 'median'),
-            id='fixation_centroid_value_error_method',
-        ),
-        pytest.param(
-            event_properties.fixation_centroid,
-            {'component': 'foo'},
-            ValueError,
-            ('component', 'foo', 'not', 'supported', 'x', 'y'),
-            id='fixation_centroid_value_error_component',
+            id='position_unsupported_method_raises_value_error',
         ),
     ],
 )
@@ -453,87 +446,49 @@ def test_property_exceptions(event_property, init_kwargs, input_df, exception, m
             id='amplitude_two_samples_xy_move',
         ),
         pytest.param(
-            event_properties.fixation_centroid,
-            {'component': 'x'},
-            pl.DataFrame(
-                {'x_pos': [0, 1], 'y_pos': [0, 0]},
-                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
-            ),
-            pl.DataFrame(
-                {'fixation_centroid': [0.5]},
-                schema={'fixation_centroid': pl.Float64},
-            ),
-            id='fixation_centroid_x_two_samples',
-        ),
-        pytest.param(
-            event_properties.fixation_centroid,
-            {'component': 'x'},
-            pl.DataFrame(
-                {'x_pos': [0, 1, 3], 'y_pos': [0, 0, 0]},
-                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
-            ),
-            pl.DataFrame(
-                {'fixation_centroid': [1.333333]},
-                schema={'fixation_centroid': pl.Float64},
-            ),
-            id='fixation_centroid_x_three_samples_float',
-        ),
-        pytest.param(
-            event_properties.fixation_centroid,
-            {'method': 'median', 'component': 'x'},
+            event_properties.position,
+            {'method': 'mean'},
             pl.DataFrame(
-                {'x_pos': [0, 1, 3], 'y_pos': [0, 2, 3]},
-                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+                {'position': [[0, 0], [1, 0]]},
+                schema={'position': pl.List(pl.Float64)},
             ),
             pl.DataFrame(
-                {'fixation_centroid': [1]},
-                schema={'fixation_centroid': pl.Float64},
+                {'position': [[0.5, 0]]},
+                schema={'position': pl.List(pl.Float64)},
             ),
-            id='fixation_centroid_x_median',
+            id='position_two_samples_mean',
         ),
         pytest.param(
-            event_properties.fixation_centroid,
-            {'component': 'y'},
+            event_properties.position,
+            {'method': 'mean'},
             pl.DataFrame(
-                {'x_pos': [0, 1], 'y_pos': [0, 5]},
-                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+                {'position': [[0, 0], [0, 1], [0, 3]]},
+                schema={'position': pl.List(pl.Float64)},
             ),
             pl.DataFrame(
-                {'fixation_centroid': [2.5]},
-                schema={'fixation_centroid': pl.Float64},
+                {'position': [[0, 1.3333333333333333]]},
+                schema={'position': pl.List(pl.Float64)},
             ),
-            id='fixation_centroid_y_two_samples',
+            id='position_three_samples_mean',
         ),
         pytest.param(
-            event_properties.fixation_centroid,
-            {'component': 'y'},
+            event_properties.position,
+            {'method': 'median'},
             pl.DataFrame(
-                {'x_pos': [0, 0, 0], 'y_pos': [0, 2, 4]},
-                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+                {'position': [[0, 0], [2, 1], [3, 3]]},
+                schema={'position': pl.List(pl.Float64)},
             ),
             pl.DataFrame(
-                {'fixation_centroid': [2]},
-                schema={'fixation_centroid': pl.Float64},
+                {'position': [[2, 1]]},
+                schema={'position': pl.List(pl.Float64)},
             ),
-            id='fixation_centroid_y_three_samples_float',
-        ),
-        pytest.param(
-            event_properties.fixation_centroid,
-            {'method': 'median', 'component': 'y'},
-            pl.DataFrame(
-                {'x_pos': [0, 1, 3], 'y_pos': [0, 2, 3]},
-                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
-            ),
-            pl.DataFrame(
-                {'fixation_centroid': [2]},
-                schema={'fixation_centroid': pl.Float64},
-            ),
-            id='fixation_centroid_y_median',
+            id='position_three_samples_median',
         ),
     ],
 )
 def test_property_has_expected_result(event_property, init_kwargs, input_df, expected_df):
-    result_df = input_df.select([event_property(**init_kwargs).alias(event_property.__name__)])
+    expression = event_property(**init_kwargs).alias(event_property.__name__)
+    result_df = input_df.select([expression])
 
     assert_frame_equal(result_df, expected_df)
 
@@ -546,11 +501,7 @@ def test_property_has_expected_result(event_property, init_kwargs, input_df, exp
         pytest.param(event_properties.dispersion, 'dispersion', id='dispersion'),
         pytest.param(event_properties.amplitude, 'amplitude', id='amplitude'),
         pytest.param(event_properties.disposition, 'disposition', id='disposition'),
-        pytest.param(
-            event_properties.fixation_centroid,
-            'fixation_centroid',
-            id='fixation_centroid',
-        ),
+        pytest.param(event_properties.position, 'position', id='position'),
     ],
 )
 def test_property_registered(property_function, property_function_name):