Repository: aeye-lab/pymovements
Pull request #426 (2023-05-29 14:12:23)

feat(events): Add support for passing keyword arguments to event processors
## Description

The location property has a keyword argument `method` which could not be passed using the `compute_event_property` method.

## Implemented changes

We now can pass additional keyword arguments for each property by using `(location, {'method': 'median'})` 

## 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] tests for using median and mean for the location property

## 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/426?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 [(`2afecda`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/2afecda611284d4429be8b1a9915e516988f4fd3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`8b5ceb7`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/426?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00%.

> :exclamation: Current head 8b5ceb7 differs from pull request most recent head e5d4c1d. Consider uploading reports for the commit e5d4c1d to get more accurate results

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


```diff
@@            Coverage Diff            @@
##              main      #426   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         1908      1918   +10     
  Branches       425       427    +2     
=========================================
+ Hits          1908      1918   +10     
```


| [Impacted Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/426?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/dataset/dataset.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/426?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldC5weQ==) | `100.00% <ø> (ø)` | |
| [src/pymovements/events/\_\_init\_\_.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/426?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/426?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/426?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/426?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/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index 20f2d34..fc633ae 100644
--- a/src/pymovements/dataset/dataset.py
+++ b/src/pymovements/dataset/dataset.py
@@ -458,7 +458,8 @@ class Dataset:
 
     def compute_event_properties(
             self,
-            event_properties: str | list[str],
+            event_properties: str | tuple[str, dict[str, Any]]
+            | list[str | tuple[str, dict[str, Any]]],
             verbose: bool = True,
     ) -> Dataset:
         """Calculate an event property for and add it as a column to the event dataframe.
diff --git a/src/pymovements/events/event_processing.py b/src/pymovements/events/event_processing.py
index a95635b..19a05f6 100644
--- a/src/pymovements/events/event_processing.py
+++ b/src/pymovements/events/event_processing.py
@@ -103,7 +103,11 @@ class EventGazeProcessor:
         A list of property names.
     """
 
-    def __init__(self, event_properties: str | list[str]):
+    def __init__(
+            self,
+            event_properties: str | tuple[str, dict[str, Any]]
+            | list[str | tuple[str, dict[str, Any]]],
+    ):
         """Initialize processor with event property definitions.
 
         Parameters
@@ -111,17 +115,27 @@ class EventGazeProcessor:
         event_properties:
             List of event property names.
         """
-        if isinstance(event_properties, str):
+        if isinstance(event_properties, (str, tuple)):
             event_properties = [event_properties]
 
-        for property_name in event_properties:
+        event_properties_with_kwargs = []
+        for event_property in event_properties:
+            if isinstance(event_property, str):
+                property_name = event_property
+                property_kwargs = {}
+            else:
+                property_name = event_property[0]
+                property_kwargs = event_property[1]
+
             if property_name not in EVENT_PROPERTIES:
                 valid_properties = list(EVENT_PROPERTIES.keys())
                 raise InvalidProperty(
                     property_name=property_name, valid_properties=valid_properties,
                 )
 
-        self.event_properties = event_properties
+            event_properties_with_kwargs.append((property_name, property_kwargs))
+
+        self.event_properties: list[tuple[str, dict[str, Any]]] = event_properties_with_kwargs
 
     def process(
             self,
@@ -162,10 +176,15 @@ class EventGazeProcessor:
         if len(trial_identifiers) == 0:
             raise ValueError('list of identifiers must not be empty')
 
-        property_expressions: dict[str, Callable[..., pl.Expr]] = {
-            property_name: EVENT_PROPERTIES[property_name]
-            for property_name in self.event_properties
-        }
+        property_expressions: list[Callable[..., pl.Expr]] = [
+            EVENT_PROPERTIES[property_name] for property_name, _ in self.event_properties
+        ]
+
+        property_names: list[str] = [property_name for property_name, _ in self.event_properties]
+
+        property_kwargs: list[dict[str, Any]] = [
+            property_kwargs for _, property_kwargs in self.event_properties
+        ]
 
         # 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
@@ -185,17 +204,18 @@ class EventGazeProcessor:
                 .alias('velocity'),
             )
 
-        property_kwargs: dict[str, dict[str, Any]] = {
-            property_name: {} for property_name in property_expressions.keys()
-        }
-        for property_name, property_expression in property_expressions.items():
+        for property_id, property_expression in enumerate(property_expressions):
             property_args = inspect.getfullargspec(property_expression).kwonlyargs
 
             if 'position_column' in property_args:
-                property_kwargs[property_name]['position_column'] = 'position'
+                property_kwargs[property_id]['position_column'] = 'position'
 
             if 'velocity_column' in property_args:
-                property_kwargs[property_name]['velocity_column'] = 'velocity'
+                property_kwargs[property_id]['velocity_column'] = 'velocity'
+
+        # Each event is uniquely defined by a list of trial identifiers,
+        # a name and its on- and offset.
+        event_identifiers = [*trial_identifiers, 'name', 'onset', 'offset']
 
         # Each event is uniquely defined by a list of trial identifiers,
         # a name and its on- and offset.
@@ -207,9 +227,10 @@ class EventGazeProcessor:
             .groupby(event_identifiers, maintain_order=True)
             .agg(
                 [
-                    property_expression(**property_kwargs[property_name])
-                    .alias(property_name)
-                    for property_name, property_expression in property_expressions.items()
+                    this_property_expression(**this_property_kwargs)
+                    .alias(this_property_name)
+                    for this_property_name, this_property_expression, this_property_kwargs,
+                    in zip(property_names, property_expressions, property_kwargs)
                 ],
             )
         )


Test patch:
diff --git a/tests/events/event_processing_test.py b/tests/events/event_processing_test.py
index a805fd4..a0a3e50 100644
--- a/tests/events/event_processing_test.py
+++ b/tests/events/event_processing_test.py
@@ -102,10 +102,10 @@ def test_event_processor_process_correct_result(
 @pytest.mark.parametrize(
     ('args', 'kwargs', 'expected_property_definitions'),
     [
-        pytest.param(['peak_velocity'], {}, ['peak_velocity'], id='arg_str_peak_velocity'),
-        pytest.param([['peak_velocity']], {}, ['peak_velocity'], id='arg_list_peak_velocity'),
+        pytest.param(['peak_velocity'], {}, [('peak_velocity', {})], id='arg_str_peak_velocity'),
+        pytest.param([['peak_velocity']], {}, [('peak_velocity', {})], id='arg_list_peak_velocity'),
         pytest.param(
-            [], {'event_properties': 'peak_velocity'}, ['peak_velocity'],
+            [], {'event_properties': 'peak_velocity'}, [('peak_velocity', {})],
             id='kwarg_properties_peak_velocity',
         ),
     ],
@@ -415,6 +415,88 @@ def test_event_gaze_processor_init_exceptions(args, kwargs, exception, msg_subst
             ),
             id='two_events_different_names_different_location',
         ),
+        pytest.param(
+            pl.from_dict(
+                {'subject_id': [1, 1], 'name': ['A', 'B'], 'onset': [0, 80], 'offset': [10, 100]},
+                schema={
+                    'subject_id': pl.Int64, 'name': pl.Utf8, 'onset': pl.Int64, 'offset': pl.Int64,
+                },
+            ),
+            pl.from_dict(
+                {
+                    'subject_id': np.ones(100),
+                    'time': np.arange(100),
+                    'x_pos': np.concatenate([np.ones(11), np.zeros(69), 2 * np.ones(19), [200]]),
+                    'y_pos': np.concatenate([np.ones(11), np.zeros(69), 2 * np.ones(19), [200]]),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_pos': pl.Float64,
+                    'y_pos': pl.Float64,
+                },
+            ),
+            {'event_properties': ('location', {'method': 'mean'})},
+            {'identifiers': 'subject_id'},
+            pl.from_dict(
+                {
+                    'subject_id': [1, 1],
+                    'name': ['A', 'B'],
+                    'onset': [0, 80],
+                    'offset': [10, 100],
+                    'location': [[1.0, 1.0], [11.9, 11.9]],
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'name': pl.Utf8,
+                    'onset': pl.Int64,
+                    'offset': pl.Int64,
+                    'location': pl.List(pl.Float64),
+                },
+            ),
+            id='two_events_location_method_mean',
+        ),
+        pytest.param(
+            pl.from_dict(
+                {'subject_id': [1, 1], 'name': ['A', 'B'], 'onset': [0, 80], 'offset': [10, 100]},
+                schema={
+                    'subject_id': pl.Int64, 'name': pl.Utf8, 'onset': pl.Int64, 'offset': pl.Int64,
+                },
+            ),
+            pl.from_dict(
+                {
+                    'subject_id': np.ones(100),
+                    'time': np.arange(100),
+                    'x_pos': np.concatenate([np.ones(11), np.zeros(69), 2 * np.ones(19), [200]]),
+                    'y_pos': np.concatenate([np.ones(11), np.zeros(69), 2 * np.ones(19), [200]]),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_pos': pl.Float64,
+                    'y_pos': pl.Float64,
+                },
+            ),
+            {'event_properties': ('location', {'method': 'median'})},
+            {'identifiers': 'subject_id'},
+            pl.from_dict(
+                {
+                    'subject_id': [1, 1],
+                    'name': ['A', 'B'],
+                    'onset': [0, 80],
+                    'offset': [10, 100],
+                    'location': [[1, 1], [2, 2]],
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'name': pl.Utf8,
+                    'onset': pl.Int64,
+                    'offset': pl.Int64,
+                    'location': pl.List(pl.Float64),
+                },
+            ),
+            id='two_events_location_method_median',
+        ),
     ],
 )
 def test_event_gaze_processor_process_correct_result(