Repository: aeye-lab/pymovements
Pull request #554 (2023-09-14 08:14:42)

feat: Add events to gaze integration functions
## Description

Add newly added `events` keyword argument to `GazeDataFrame.__init__()`.

Depends on #552 and #553

## Implemented changes

- [x] Added events to gaze.from_numpy()
- [x] Added events to gaze.from_pandas()

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- [x] Added basic test cases for from_numpy()
- [x] Added basic test cases for from_pandas()

## 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/554?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 [(`7e33adf`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/7e33adfa1037fb3b7b234fa3c2815e0cd9498f3e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`3b743a0`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/554?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      #554   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           48        48           
  Lines         2137      2144    +7     
  Branches       521       475   -46     
=========================================
+ Hits          2137      2144    +7     
```


| [Files Changed](https://app.codecov.io/gh/aeye-lab/pymovements/pull/554?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/frame.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/554?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9mcmFtZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/gaze\_dataframe.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/554?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%> (ø)` | |
| [src/pymovements/gaze/integration.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/554?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvaW50ZWdyYXRpb24ucHk=) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/554?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Have feedback on the report? [Share it here](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/__init__.py b/src/pymovements/__init__.py
index fd52b48..d759177 100644
--- a/src/pymovements/__init__.py
+++ b/src/pymovements/__init__.py
@@ -21,6 +21,7 @@
 from pymovements import _version
 from pymovements import datasets
 from pymovements import events
+from pymovements import exceptions
 from pymovements import gaze
 from pymovements import plotting
 from pymovements import synthetic
@@ -56,6 +57,7 @@ __all__ = [
     'Screen',
     'GazeDataFrame',
 
+    'exceptions',
     'plotting',
     'synthetic',
     'utils',
diff --git a/src/pymovements/events/processing.py b/src/pymovements/events/processing.py
index 15a51bf..5f8b579 100644
--- a/src/pymovements/events/processing.py
+++ b/src/pymovements/events/processing.py
@@ -26,10 +26,10 @@ from typing import Any
 
 import polars as pl
 
+import pymovements as pm  # pylint: disable=cyclic-import
 from pymovements.events.frame import EventDataFrame
 from pymovements.events.properties import EVENT_PROPERTIES
 from pymovements.exceptions import InvalidProperty
-from pymovements.gaze.gaze_dataframe import GazeDataFrame
 
 
 class EventProcessor:
@@ -140,7 +140,7 @@ class EventGazeProcessor:
     def process(
             self,
             events: EventDataFrame,
-            gaze: GazeDataFrame,
+            gaze: pm.GazeDataFrame,
             identifiers: str | list[str],
             name: str | None = None,
     ) -> pl.DataFrame:
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index 5561b35..3cfb3a1 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -27,6 +27,7 @@ from typing import Any
 
 import polars as pl
 
+import pymovements as pm  # pylint: disable=cyclic-import
 from pymovements.gaze import transforms
 from pymovements.gaze.experiment import Experiment
 from pymovements.utils import checks
@@ -79,6 +80,7 @@ class GazeDataFrame:
             self,
             data: pl.DataFrame | None = None,
             experiment: Experiment | None = None,
+            events: pm.EventDataFrame | None = None,
             *,
             trial_columns: str | list[str] | None = None,
             time_column: str | None = None,
@@ -95,6 +97,8 @@ class GazeDataFrame:
             A dataframe to be transformed to a polars dataframe.
         experiment : Experiment
             The experiment definition.
+        events: EventDataFrame
+            A dataframe of events in the gaze signal.
         trial_columns:
             The name of the trial columns in the input data frame. If the list is empty or None,
             the input data frame is assumed to contain only one trial. If the list is not empty,
@@ -214,6 +218,11 @@ class GazeDataFrame:
         self.n_components = _infer_n_components(self.frame, column_specifiers)
         self.experiment = experiment
 
+        if events is None:
+            self.events = pm.EventDataFrame()
+        else:
+            self.events = events.copy()
+
     def transform(
             self,
             transform_method: str | Callable[..., pl.Expr],
diff --git a/src/pymovements/gaze/integration.py b/src/pymovements/gaze/integration.py
index ec7b5f7..0d3554c 100644
--- a/src/pymovements/gaze/integration.py
+++ b/src/pymovements/gaze/integration.py
@@ -26,6 +26,7 @@ import numpy as np
 import pandas as pd
 import polars as pl
 
+from pymovements.events.frame import EventDataFrame
 from pymovements.gaze.experiment import Experiment
 from pymovements.gaze.gaze_dataframe import GazeDataFrame
 from pymovements.utils import checks
@@ -33,13 +34,15 @@ from pymovements.utils import checks
 
 def from_numpy(
         data: np.ndarray | None = None,
+        experiment: Experiment | None = None,
+        events: EventDataFrame | None = None,
+        *,
         time: np.ndarray | None = None,
         pixel: np.ndarray | None = None,
         position: np.ndarray | None = None,
         velocity: np.ndarray | None = None,
         acceleration: np.ndarray | None = None,
         schema: list[str] | None = None,
-        experiment: Experiment | None = None,
         orient: Literal['col', 'row'] = 'col',
         time_column: str | None = None,
         pixel_columns: list[str] | None = None,
@@ -63,6 +66,10 @@ def from_numpy(
     ----------
     data:
         Two-dimensional data represented as a numpy ndarray.
+    experiment : Experiment
+        The experiment definition.
+    events: EventDataFrame
+        A dataframe of events in the gaze signal.
     time:
         Array of timestamps.
     pixel:
@@ -77,9 +84,6 @@ def from_numpy(
         A list of column names.
     orient:
         Whether to interpret the two-dimensional data as columns or as rows.
-    experiment : Experiment
-        The experiment definition.
-            time_column: str | None = None,
     time_column:
         The name of the timestamp column in the input data frame.
     pixel_columns:
@@ -200,6 +204,7 @@ def from_numpy(
         return GazeDataFrame(
             data=df,
             experiment=experiment,
+            events=events,
             time_column=time_column,
             pixel_columns=pixel_columns,
             position_columns=position_columns,
@@ -245,6 +250,7 @@ def from_numpy(
     return GazeDataFrame(
         data=df,
         experiment=experiment,
+        events=events,
         time_column=time_column,
         pixel_columns=pixel_columns,
         position_columns=position_columns,
@@ -256,6 +262,8 @@ def from_numpy(
 def from_pandas(
         data: pd.DataFrame,
         experiment: Experiment | None = None,
+        events: EventDataFrame | None = None,
+        *,
         time_column: str | None = None,
         pixel_columns: list[str] | None = None,
         position_columns: list[str] | None = None,
@@ -270,6 +278,8 @@ def from_pandas(
         Data represented as a pandas DataFrame.
     experiment : Experiment
         The experiment definition.
+    events: EventDataFrame
+        A dataframe of events in the gaze signal.
     time_column:
         The name of the timestamp column in the input data frame.
     pixel_columns:
@@ -289,6 +299,7 @@ def from_pandas(
     return GazeDataFrame(
         data=df,
         experiment=experiment,
+        events=events,
         time_column=time_column,
         pixel_columns=pixel_columns,
         position_columns=position_columns,


Test patch:
diff --git a/tests/gaze/gaze_init_test.py b/tests/gaze/gaze_init_test.py
index f74b87c..b0b510e 100644
--- a/tests/gaze/gaze_init_test.py
+++ b/tests/gaze/gaze_init_test.py
@@ -23,7 +23,7 @@ import polars as pl
 import pytest
 from polars.testing import assert_frame_equal
 
-from pymovements.gaze.gaze_dataframe import GazeDataFrame
+import pymovements as pm
 
 
 @pytest.mark.parametrize(
@@ -704,7 +704,7 @@ from pymovements.gaze.gaze_dataframe import GazeDataFrame
     ],
 )
 def test_init_gaze_dataframe_has_expected_attrs(init_kwargs, expected_frame, expected_n_components):
-    gaze = GazeDataFrame(**init_kwargs)
+    gaze = pm.GazeDataFrame(**init_kwargs)
     assert_frame_equal(gaze.frame, expected_frame)
     assert gaze.n_components == expected_n_components
 
@@ -1175,7 +1175,7 @@ def test_init_gaze_dataframe_has_expected_attrs(init_kwargs, expected_frame, exp
 )
 def test_gaze_dataframe_init_exceptions(init_kwargs, exception, exception_msg):
     with pytest.raises(exception) as excinfo:
-        GazeDataFrame(**init_kwargs)
+        pm.GazeDataFrame(**init_kwargs)
 
     msg, = excinfo.value.args
     assert msg == exception_msg
@@ -1187,9 +1187,71 @@ def test_gaze_copy_init_has_same_n_components():
     Refers to issue #514.
     """
     df_orig = pl.from_numpy(np.zeros((2, 1000)), orient='col', schema=['x', 'y'])
-    gaze = GazeDataFrame(df_orig, position_columns=['x', 'y'])
+    gaze = pm.GazeDataFrame(df_orig, position_columns=['x', 'y'])
 
     df_copy = gaze.frame.clone()
-    gaze_copy = GazeDataFrame(df_copy)
+    gaze_copy = pm.GazeDataFrame(df_copy)
 
     assert gaze.n_components == gaze_copy.n_components
+
+
+@pytest.mark.parametrize(
+    ('events', 'init_kwargs'),
+    [
+        pytest.param(
+            None,
+            {
+                'data': pl.from_dict(
+                    {'x': [1.23], 'y': [4.56]}, schema={'x': pl.Float64, 'y': pl.Float64},
+                ),
+                'position_columns': ['x', 'y'],
+            },
+            id='data_with_no_events',
+        ),
+
+        pytest.param(
+            pm.EventDataFrame(),
+            {
+                'data': pl.from_dict(
+                    {'x': [1.23], 'y': [4.56]}, schema={'x': pl.Float64, 'y': pl.Float64},
+                ),
+                'position_columns': ['x', 'y'],
+            },
+            id='data_empty_events',
+        ),
+
+        pytest.param(
+            pm.EventDataFrame(),
+            {},
+            id='no_data_empty_events',
+        ),
+
+        pytest.param(
+            pm.EventDataFrame(name='saccade', onsets=[0], offsets=[10]),
+            {},
+            id='no_data_with_saccades',
+        ),
+
+        pytest.param(
+            pm.EventDataFrame(name='fixation', onsets=[100], offsets=[910]),
+            {
+                'data': pl.from_dict(
+                    {'x': [1.23], 'y': [4.56]}, schema={'x': pl.Float64, 'y': pl.Float64},
+                ),
+                'position_columns': ['x', 'y'],
+            },
+            id='data_with_fixations',
+        ),
+    ],
+)
+def test_gaze_init_events(events, init_kwargs):
+    if events is None:
+        expected_events = pm.EventDataFrame().frame
+    else:
+        expected_events = events.frame
+
+    gaze = pm.GazeDataFrame(events=events, **init_kwargs)
+
+    assert_frame_equal(gaze.events.frame, expected_events)
+    # We don't want the events point to the same reference.
+    assert gaze.events.frame is not expected_events
diff --git a/tests/gaze/integration_numpy_test.py b/tests/gaze/integration_numpy_test.py
index ae39855..ed61afe 100644
--- a/tests/gaze/integration_numpy_test.py
+++ b/tests/gaze/integration_numpy_test.py
@@ -20,6 +20,7 @@
 """Test from gaze.from_numpy."""
 import numpy as np
 import polars as pl
+import pytest
 from polars.testing import assert_frame_equal
 
 import pymovements as pm
@@ -164,7 +165,7 @@ def test_from_numpy_explicit_columns():
     assert gaze.n_components == 2
 
 
-def test_init_all_none():
+def test_from_numpy_all_none():
     gaze = pm.gaze.from_numpy(
         data=None,
         schema=None,
@@ -185,3 +186,41 @@ def test_init_all_none():
 
     assert_frame_equal(gaze.frame, expected)
     assert gaze.n_components is None
+
+
+@pytest.mark.parametrize(
+    'events',
+    [
+        pytest.param(
+            None,
+            id='events_none',
+        ),
+
+        pytest.param(
+            pm.EventDataFrame(),
+            id='events_empty',
+        ),
+
+        pytest.param(
+            pm.EventDataFrame(name='fixation', onsets=[123], offsets=[345]),
+            id='fixation',
+        ),
+
+        pytest.param(
+            pm.EventDataFrame(name='saccade', onsets=[34123], offsets=[67345]),
+            id='saccade',
+        ),
+
+    ],
+)
+def test_from_numpy_events(events):
+    if events is None:
+        expected_events = pm.EventDataFrame().frame
+    else:
+        expected_events = events.frame
+
+    gaze = pm.gaze.from_numpy(events=events)
+
+    assert_frame_equal(gaze.events.frame, expected_events)
+    # We don't want the events point to the same reference.
+    assert gaze.events.frame is not expected_events
diff --git a/tests/gaze/integration_pandas_test.py b/tests/gaze/integration_pandas_test.py
index 2d1dc28..082c3b8 100644
--- a/tests/gaze/integration_pandas_test.py
+++ b/tests/gaze/integration_pandas_test.py
@@ -20,6 +20,7 @@
 """Test from gaze.from_pandas."""
 import pandas as pd
 import polars as pl
+import pytest
 from polars.testing import assert_frame_equal
 
 import pymovements as pm
@@ -35,15 +36,7 @@ def test_from_pandas():
         },
     )
 
-    experiment = pm.Experiment(
-        screen_width_px=1280,
-        screen_height_px=1024,
-        screen_width_cm=38,
-        screen_height_cm=30,
-        distance_cm=68,
-        origin='lower left',
-        sampling_rate=1000.0,
-    )
+    experiment = pm.Experiment(1280, 1024, 38, 30, 68, 'lower left', 1000.0)
 
     gaze = pm.gaze.from_pandas(
         data=pandas_df,
@@ -64,15 +57,7 @@ def test_from_pandas_explicit_columns():
         },
     )
 
-    experiment = pm.Experiment(
-        screen_width_px=1280,
-        screen_height_px=1024,
-        screen_width_cm=38,
-        screen_height_cm=30,
-        distance_cm=68,
-        origin='lower left',
-        sampling_rate=1000.0,
-    )
+    experiment = pm.Experiment(1280, 1024, 38, 30, 68, 'lower left', 1000.0)
 
     gaze = pm.gaze.from_pandas(
         data=pandas_df,
@@ -87,3 +72,45 @@ def test_from_pandas_explicit_columns():
     })
 
     assert_frame_equal(gaze.frame, expected)
+
+
+@pytest.mark.parametrize(
+    ('df', 'events'),
+    [
+        pytest.param(
+            pd.DataFrame(),
+            None,
+            id='events_none',
+        ),
+
+        pytest.param(
+            pd.DataFrame(),
+            pm.EventDataFrame(),
+            id='events_empty',
+        ),
+
+        pytest.param(
+            pd.DataFrame(),
+            pm.EventDataFrame(name='fixation', onsets=[123], offsets=[345]),
+            id='fixation',
+        ),
+
+        pytest.param(
+            pd.DataFrame(),
+            pm.EventDataFrame(name='saccade', onsets=[34123], offsets=[67345]),
+            id='saccade',
+        ),
+
+    ],
+)
+def test_from_pandas_events(df, events):
+    if events is None:
+        expected_events = pm.EventDataFrame().frame
+    else:
+        expected_events = events.frame
+
+    gaze = pm.gaze.from_pandas(data=df, events=events)
+
+    assert_frame_equal(gaze.events.frame, expected_events)
+    # We don't want the events point to the same reference.
+    assert gaze.events.frame is not expected_events