Repository: aeye-lab/pymovements
Pull request #655 (2024-01-11 19:08:32)

feat: trialize gaze.detect()
## Description

With this PR event detection is finally grouped by trials.

Fixes last task in issue #382 

please review/merge this issue before: #671 

## Implemented changes

- [x] pass signals and events grouped by trials

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- [x] added test cases with trial columns for each event detection method
- [x] added test cases for fill to ensure that passed events are grouped accordingly

## 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/655?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`4fd1b18`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/4fd1b18320bd401495779307e5895a80f9cf1502?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`ec7bd64`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/655?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 99.95%.


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


```diff
@@             Coverage Diff             @@
##              main     #655      +/-   ##
===========================================
- Coverage   100.00%   99.95%   -0.05%     
===========================================
  Files           54       54              
  Lines         2356     2373      +17     
  Branches       588      593       +5     
===========================================
+ Hits          2356     2372      +16     
- Partials         0        1       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/655?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/events/frame.py b/src/pymovements/events/frame.py
index d6c34ae..cec000d 100644
--- a/src/pymovements/events/frame.py
+++ b/src/pymovements/events/frame.py
@@ -181,6 +181,45 @@ class EventDataFrame:
         """
         self.frame = self.frame.join(event_properties, on=join_on, how='left')
 
+    def add_trial_column(
+            self,
+            column: str | list[str],
+            data: int | float | str | list[int | float | str] | None,
+    ) -> None:
+        """Add new trial columns with constant values.
+
+        Parameters
+        ----------
+        column: str | list[str]
+            The name(s) of the new trial column(s).
+        data: int | float | str | list[int | float | str] | None
+            The values to be used for filling the trial column(s). In case multiple columns are
+            provided, data must be a list of values matching the provided column order.
+        """
+        # Create trial column dictionary to iterate over in select().
+        if isinstance(column, str):
+            trial_columns = {column: data}
+        # In case a list of a single column is passed as an explicit value.
+        elif len(column) == 1 and (isinstance(data, (int, float, str) or data is None)):
+            trial_columns = {column[0]: data}
+        else:
+            if not isinstance(data, Sequence):
+                raise TypeError(
+                    'data must be passed as a list of values in case of providing multiple columns',
+                )
+            checks.check_is_length_matching(column=column, data=data)
+
+            trial_columns = dict(zip(column, data))
+
+        self.frame = self.frame.select(
+            [
+                pl.lit(column_data).alias(column_name) if not isinstance(column_data, int)
+                # Enforce Int64 columns for integers.
+                else pl.lit(column_data).alias(column_name).cast(pl.Int64)
+                for column_name, column_data in trial_columns.items()
+            ] + [pl.all()],
+        )
+
     @property
     def event_property_columns(self) -> list[str]:
         """Event property columns for this dataframe.
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index b012cb4..2f68bc0 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -243,13 +243,15 @@ class GazeDataFrame:
         # In case the 'time' column is already present we don't need to do anything.
         # Otherwise, create a new time column starting with zero.
         if time_column is None and 'time' not in self.frame.columns:
-            timesteps = pl.arange(0, len(self.frame))
-
             # In case we have an experiment with sampling rate given, we convert to milliseconds.
             if experiment is not None and experiment.sampling_rate is not None:
-                timesteps = timesteps * (1000 / experiment.sampling_rate)
+                sampling_rate_factor = 1000 / experiment.sampling_rate
+            else:
+                sampling_rate_factor = 1
 
-            self.frame = self.frame.with_columns(time=timesteps)
+            self.frame = self.frame.with_columns(
+                time=pl.arange(0, len(self.frame)) * sampling_rate_factor,
+            )
 
         # This if clause is mutually exclusive with the previous one.
         if time_column is not None:
@@ -286,7 +288,15 @@ class GazeDataFrame:
         self.experiment = experiment
 
         if events is None:
-            self.events = pm.EventDataFrame()
+            if self.trial_columns is None:
+                self.events = pm.EventDataFrame()
+            else:  # Ensure that trial columns with correct dtype are present in event dataframe.
+                self.events = pm.EventDataFrame(
+                    data=pl.DataFrame(
+                        schema={column: self.frame.schema[column] for column in self.trial_columns},
+                    ),
+                    trial_columns=self.trial_columns,
+                )
         else:
             self.events = events.copy()
 
@@ -573,19 +583,87 @@ class GazeDataFrame:
         **kwargs: Any
             Additional keyword arguments to be passed to the event detection method.
         """
-        if not self.events or clear:
-            self.events = pm.EventDataFrame()
+        if self.events is None or clear:
+            if self.trial_columns is None:
+                self.events = pm.EventDataFrame()
+            else:  # Ensure that trial columns with correct dtype are present in event dataframe.
+                self.events = pm.EventDataFrame(
+                    data=pl.DataFrame(
+                        schema={column: self.frame.schema[column] for column in self.trial_columns},
+                    ),
+                    trial_columns=self.trial_columns,
+                )
 
         if isinstance(method, str):
             method = pm.events.EventDetectionLibrary.get(method)
 
-        method_kwargs = self._fill_event_detection_kwargs(method, eye, **kwargs)
-        new_events = method(**method_kwargs)
+        if self.n_components is not None:
+            eye_components = self._infer_eye_components(eye)
+        else:
+            eye_components = None
+
+        if self.trial_columns is None:
+            method_kwargs = self._fill_event_detection_kwargs(
+                method,
+                gaze=self.frame,
+                events=self.events,
+                eye_components=eye_components,
+                **kwargs,
+            )
 
-        self.events.frame = pl.concat(
-            [self.events.frame, new_events.frame],
-            how='diagonal',
-        )
+            new_events = method(**method_kwargs)
+
+            self.events.frame = pl.concat(
+                [self.events.frame, new_events.frame],
+                how='diagonal',
+            )
+        else:
+            grouped_frames = self.frame.partition_by(
+                self.trial_columns, maintain_order=True, include_key=True, as_dict=True,
+            )
+
+            missing_trial_columns = [
+                trial_column for trial_column in self.trial_columns
+                if trial_column not in self.events.frame.columns
+            ]
+            if missing_trial_columns:
+                raise pl.ColumnNotFoundError(
+                    f'trial columns {missing_trial_columns} missing from events, '
+                    f'available columns: {self.events.frame.columns}',
+                )
+
+            new_events_grouped: list[pl.DataFrame] = []
+
+            for group_identifier, group_gaze in grouped_frames.items():
+                # Create filter expression for selecting respective group rows.
+                if len(self.trial_columns) == 1:
+                    group_filter_expression = pl.col(self.trial_columns[0]) == group_identifier
+                else:
+                    group_filter_expression = pl.col(self.trial_columns[0]) == group_identifier[0]
+                    for name, value in zip(self.trial_columns[1:], group_identifier[1:]):
+                        group_filter_expression = group_filter_expression & pl.col(name) == value
+
+                # Select group events
+                group_events = pm.EventDataFrame(self.events.frame.filter(group_filter_expression))
+
+                method_kwargs = self._fill_event_detection_kwargs(
+                    method,
+                    gaze=group_gaze,
+                    events=group_events,
+                    eye_components=eye_components,
+                    **kwargs,
+                )
+
+                new_events = method(**method_kwargs)
+                # add group identifiers as new columns
+                new_events.add_trial_column(self.trial_columns, group_identifier)
+
+                new_events_grouped.append(new_events.frame)
+
+            self.events.frame = pl.concat(
+                [self.events.frame, *new_events_grouped],
+                how='diagonal',
+            )
 
     @property
     def schema(self) -> pl.type_aliases.SchemaDict:
@@ -899,17 +977,23 @@ class GazeDataFrame:
     def _fill_event_detection_kwargs(
             self,
             method: Callable[..., pm.EventDataFrame],
-            eye: str,
+            gaze: pl.DataFrame,
+            events: pm.EventDataFrame,
+            eye_components: tuple[int, int] | None,
             **kwargs: Any,
     ) -> dict[str, Any]:
-        """Fill event detection kwargs with gaze attributes.
+        """Fill event detection method kwargs with gaze attributes.
 
         Parameters
         ----------
         method: Callable[..., pm.EventDataFrame]
             The method for which the keyword argument dictionary will be filled.
-        eye: str
-            The string specifier for the eye to choose.
+        gaze: pl.DataFrame
+            The gaze dataframe to be used for filling event detection keyword arguments.
+        events: pm.EventDataFrame
+            The event dataframe to be used for filling event detection keyword arguments.
+        eye_components: tuple[int, int] | None
+            The eye components to be used for filling event detection keyword arguments.
         **kwargs: Any
             The source keyword arguments passed to the `GazeDataFrame.detect()` method.
 
@@ -922,38 +1006,47 @@ class GazeDataFrame:
         method_args = inspect.getfullargspec(method).args
 
         if 'positions' in method_args:
-            if 'position' not in self.frame.columns:
+            if 'position' not in gaze.columns:
                 raise pl.exceptions.ColumnNotFoundError(
                     f'Column \'position\' not found.'
-                    f' Available columns are: {self.frame.columns}',
+                    f' Available columns are: {gaze.columns}',
                 )
-            eye_components = self._infer_eye_components(eye)
+
+            if eye_components is None:
+                raise ValueError(
+                    'eye_components must not be None if passing position to event detection',
+                )
+
             kwargs['positions'] = np.vstack(
                 [
-                    self.frame.get_column('position').list.get(eye_component)
+                    gaze.get_column('position').list.get(eye_component)
                     for eye_component in eye_components
                 ],
             ).transpose()
 
         if 'velocities' in method_args:
-            if 'velocity' not in self.frame.columns:
+            if 'velocity' not in gaze.columns:
                 raise pl.exceptions.ColumnNotFoundError(
                     f'Column \'velocity\' not found.'
-                    f' Available columns are: {self.frame.columns}',
+                    f' Available columns are: {gaze.columns}',
+                )
+
+            if eye_components is None:
+                raise ValueError(
+                    'eye_components must not be None if passing velocity to event detection',
                 )
 
-            eye_components = self._infer_eye_components(eye)
             kwargs['velocities'] = np.vstack(
                 [
-                    self.frame.get_column('velocity').list.get(eye_component)
+                    gaze.get_column('velocity').list.get(eye_component)
                     for eye_component in eye_components
                 ],
             ).transpose()
 
         if 'events' in method_args:
-            kwargs['events'] = self.events
+            kwargs['events'] = events
 
-        if 'timesteps' in method_args and 'time' in self.frame.columns:
-            kwargs['timesteps'] = self.frame.get_column('time').to_numpy()
+        if 'timesteps' in method_args and 'time' in gaze.columns:
+            kwargs['timesteps'] = gaze.get_column('time').to_numpy()
 
         return kwargs


Test patch:
diff --git a/tests/unit/events/frame_test.py b/tests/unit/events/frame_test.py
index 8b49340..c967284 100644
--- a/tests/unit/events/frame_test.py
+++ b/tests/unit/events/frame_test.py
@@ -407,3 +407,82 @@ def test_event_dataframe_copy():
     assert events is not events_copy
     assert events.frame is not events_copy.frame
     assert_frame_equal(events.frame, events_copy.frame)
+
+
+@pytest.mark.parametrize(
+    ('events', 'kwargs', 'expected_df'),
+    [
+        pytest.param(
+            pm.EventDataFrame(name='a', onsets=[0], offsets=[1]),
+            {'column': 'trial', 'data': 1},
+            pm.EventDataFrame(
+                pl.DataFrame(
+                    {'trial': [1], 'name': 'a', 'onset': [0], 'offset': [1]},
+                ),
+            ),
+            id='single_row_trial_str',
+        ),
+        pytest.param(
+            pm.EventDataFrame(name='a', onsets=[0], offsets=[1]),
+            {'column': ['trial'], 'data': 1},
+            pm.EventDataFrame(
+                pl.DataFrame(
+                    {'trial': [1], 'name': 'a', 'onset': [0], 'offset': [1]},
+                ),
+            ),
+            id='single_row_trial_list_data_int',
+        ),
+        pytest.param(
+            pm.EventDataFrame(name='a', onsets=[0], offsets=[1]),
+            {'column': ['trial'], 'data': [1]},
+            pm.EventDataFrame(
+                pl.DataFrame(
+                    {'trial': [1], 'name': 'a', 'onset': [0], 'offset': [1]},
+                ),
+            ),
+            id='single_row_trial_list_single_identifier',
+        ),
+        pytest.param(
+            pm.EventDataFrame(name='a', onsets=[0], offsets=[1]),
+            {'column': ['group', 'trial'], 'data': ['A', 1]},
+            pm.EventDataFrame(
+                pl.DataFrame(
+                    {'group': 'A', 'trial': [1], 'name': 'a', 'onset': [0], 'offset': [1]},
+                ),
+            ),
+            id='single_row_trial_list_single_identifier',
+        ),
+        pytest.param(
+            pm.EventDataFrame(name='a', onsets=[0, 8], offsets=[1, 9]),
+            {'column': ['trial'], 'data': [1]},
+            pm.EventDataFrame(
+                pl.DataFrame(
+                    {'trial': [1, 1], 'name': ['a', 'a'], 'onset': [0, 8], 'offset': [1, 9]},
+                ),
+            ),
+            id='two_rows_trial_list_single_identifier',
+        ),
+    ],
+)
+def test_event_dataframe_add_trial_column(events, kwargs, expected_df):
+    events.add_trial_column(**kwargs)
+    assert_frame_equal(events.frame, expected_df.frame)
+
+
+@pytest.mark.parametrize(
+    ('events', 'kwargs', 'exception', 'message'),
+    [
+        pytest.param(
+            pm.EventDataFrame(name='a', onsets=[0], offsets=[1]),
+            {'column': ['group', 'trial'], 'data': 1},
+            TypeError,
+            'data must be passed as a list of values in case of providing multiple columns',
+            id='multiple_columns_data_not_list',
+        ),
+    ],
+)
+def test_event_dataframe_add_trial_column_raises_exception(events, kwargs, exception, message):
+    with pytest.raises(exception) as excinfo:
+        events.add_trial_column(**kwargs)
+
+    assert message == excinfo.value.args[0]
diff --git a/tests/unit/gaze/apply_test.py b/tests/unit/gaze/apply_test.py
index 747623a..3331371 100644
--- a/tests/unit/gaze/apply_test.py
+++ b/tests/unit/gaze/apply_test.py
@@ -195,6 +195,7 @@ from pymovements.synthetic import step_function
                         'y_vel': [None, 100.0, 100.0, None, 100.0, 100.0],
                     },
                 ),
+                trial_columns='trial_id',
                 position_columns=['x_dva', 'y_dva'],
                 velocity_columns=['x_vel', 'y_vel'],
             ),
diff --git a/tests/unit/gaze/detect_test.py b/tests/unit/gaze/detect_test.py
index b19500b..c30d6d0 100644
--- a/tests/unit/gaze/detect_test.py
+++ b/tests/unit/gaze/detect_test.py
@@ -191,6 +191,22 @@ from pymovements.synthetic import step_function
             id='idt_constant_position_single_fixation_with_timesteps_int_extra_dim',
         ),
 
+        pytest.param(
+            'idt',
+            {
+                'dispersion_threshold': 1,
+                'minimum_duration': 2,
+            },
+            pm.gaze.from_numpy(
+                trial=np.array(['A'] * 50 + ['B'] * 50),
+                position=step_function(length=100, steps=[0], values=[(0, 0)]),
+                orient='row',
+                experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+            ),
+            pm.EventDataFrame(name='fixation', onsets=[0, 50], offsets=[49, 99], trials=['A', 'B']),
+            id='idt_constant_position_single_fixation_per_trial',
+        ),
+
         pytest.param(
             'ivt',
             {
@@ -476,6 +492,21 @@ from pymovements.synthetic import step_function
             id='ivt_constant_position_monocular_fixation_six_components_eye_cyclops',
         ),
 
+        pytest.param(
+            'ivt',
+            {
+                'velocity_threshold': 1,
+                'minimum_duration': 1,
+            },
+            pm.gaze.from_numpy(
+                trial=np.array(['A'] * 50 + ['B'] * 50),
+                velocity=np.zeros((2, 100)),
+                experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+            ),
+            pm.EventDataFrame(name='fixation', onsets=[0, 50], offsets=[49, 99], trials=['A', 'B']),
+            id='ivt_constant_position_single_fixation_per_trial',
+        ),
+
         pytest.param(
             'microsaccades',
             {
@@ -644,6 +675,21 @@ from pymovements.synthetic import step_function
             id='microsaccades_two_steps_one_saccade_timesteps',
         ),
 
+        pytest.param(
+            'microsaccades',
+            {
+                'threshold': 1e-5,
+            },
+            pm.gaze.from_numpy(
+                trial=np.array(['A'] * 50 + ['B'] * 50),
+                velocity=step_function(length=100, steps=[40, 60], values=[(9, 9), (0, 0)]),
+                orient='row',
+                experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+            ),
+            pm.EventDataFrame(name='saccade', onsets=[40, 50], offsets=[49, 59], trials=['A', 'B']),
+            id='microsaccades_two_steps_one_saccade_per_trial',
+        ),
+
         pytest.param(
             'fill',
             {},
@@ -717,6 +763,49 @@ from pymovements.synthetic import step_function
             id='fill_fixation_10_ms_break_then_saccade_until_end_single_fill',
         ),
 
+        pytest.param(
+            'fill',
+            {},
+            pm.gaze.from_numpy(
+                trial=np.array([1] * 50 + [2] * 50),
+                time=np.arange(0, 100),
+                events=pm.EventDataFrame(
+                    name='fixation', onsets=[0, 90], offsets=[10, 100], trials=[1, 2],
+                ),
+            ),
+            pm.EventDataFrame(
+                name=['fixation', 'unclassified', 'unclassified', 'fixation'],
+                onsets=[0, 10, 50, 90],
+                offsets=[10, 49, 89, 100],
+                trials=[1, 1, 2, 2],
+            ),
+            id='fill_10ms_fixation_at_start_and_end_one_fill_per_trial',
+        ),
+
+        pytest.param(
+            'fill',
+            {},
+            pm.gaze.from_numpy(
+                trial=np.array([1] * 50 + [2] * 50),
+                time=np.concatenate((np.arange(0, 50), np.arange(0, 50))),
+                events=pm.EventDataFrame(
+                    name='fixation', onsets=[0, 40], offsets=[10, 50], trials=[1, 2],
+                ),
+            ),
+            pm.EventDataFrame(
+                pl.DataFrame(
+                    data={
+                        'trial': [1, 1, 2, 2],
+                        'name': ['fixation', 'unclassified', 'unclassified', 'fixation'],
+                        'onset': [0, 10, 0, 40],
+                        'offset': [10, 49, 39, 50],
+                    },
+                ),
+            ),
+            id='fill_10ms_fixation_at_start_and_end_one_fill_per_trial_restarting_timesteps',
+        ),
+
+
         pytest.param(
             'fill',
             {},
@@ -767,11 +856,44 @@ from pymovements.synthetic import step_function
             ),
             id='fill_fixation_events_exceed_time_boundaries',
         ),
+
+        pytest.param(
+            'fill',
+            {'clear': True},
+            pm.gaze.from_numpy(
+                position=step_function(
+                    length=100, steps=[10, 20, 90],
+                    values=[(np.nan, np.nan), (0, 0), (np.nan, np.nan)],
+                ),
+                orient='row',
+                experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+                events=pm.events.EventDataFrame(name='fixation', onsets=[0, 20], offsets=[9, 89]),
+            ),
+            pm.events.EventDataFrame(name='unclassified', onsets=[0], offsets=[99]),
+            id='fill_clear_events_no_trials',
+        ),
+
+        pytest.param(
+            'fill',
+            {'clear': True},
+            pm.gaze.from_numpy(
+                trial=np.repeat('A', 100),
+                position=step_function(
+                    length=100, steps=[10, 20, 90],
+                    values=[(np.nan, np.nan), (0, 0), (np.nan, np.nan)],
+                ),
+                orient='row',
+                experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+                events=pm.events.EventDataFrame(name='fixation', onsets=[0, 20], offsets=[9, 89]),
+            ),
+            pm.events.EventDataFrame(name='unclassified', onsets=[0], offsets=[99], trials=['A']),
+            id='fill_clear_events_with_trials',
+        ),
     ],
 )
 def test_gaze_detect(method, kwargs, gaze, expected):
     gaze.detect(method, **kwargs)
-    assert_frame_equal(gaze.events.frame, expected.frame)
+    assert_frame_equal(gaze.events.frame, expected.frame, check_row_order=False)
 
 
 def test_gaze_detect_custom_method_no_arguments():
@@ -869,6 +991,7 @@ def test_gaze_detect_custom_method_no_arguments():
             "unknown eye 'foobar'. Supported values are: ['auto', 'left', 'right', 'cyclops']",
             id='ivt_cyclops_eye_four_components_raises_attribute_error',
         ),
+
     ],
 )
 def test_gaze_detect_raises_exception(method, kwargs, gaze, exception, exception_msg):
@@ -877,3 +1000,54 @@ def test_gaze_detect_raises_exception(method, kwargs, gaze, exception, exception
 
     msg, = exc_info.value.args
     assert msg == exception_msg
+
+
+def test_gaze_detect_missing_trial_column_events_raises_exception():
+    gaze = pm.gaze.from_numpy(
+        trial=np.ones(100),
+        velocity=step_function(length=100, steps=[0], values=[(0, 0, 0, 0)]),
+        orient='row',
+        experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10),
+    )
+    assert gaze.trial_columns
+
+    # manipulaute event dataframe to not have trial columns
+    gaze.events.frame = gaze.events.frame.drop(gaze.trial_columns)
+    assert gaze.trial_columns[0] not in gaze.events.frame.columns
+
+    with pytest.raises(pl.ColumnNotFoundError) as exc_info:
+        gaze.detect('fill')
+
+    msg, = exc_info.value.args
+    assert msg == (
+        "trial columns ['trial'] missing from events, "
+        "available columns: ['name', 'onset', 'offset', 'duration']"
+    )
+
+
+@pytest.mark.parametrize(
+    ('method', 'column'),
+    [
+        ('ivt', 'velocity'),
+        ('idt', 'position'),
+    ],
+)
+def test_gaze_detect_missing_missing_eye_components_raises_exception(method, column):
+    gaze = pm.gaze.from_numpy(
+        trial=np.ones(100),
+        position=step_function(length=100, steps=[0], values=[(0, 0, 0, 0)]),
+        velocity=step_function(length=100, steps=[0], values=[(0, 0, 0, 0)]),
+        orient='row',
+        experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10),
+    )
+    assert gaze.n_components is not None
+
+    # Manipulate n_components attribute.
+    gaze.n_components = None
+    assert gaze.n_components is None
+
+    with pytest.raises(ValueError) as exc_info:
+        gaze.detect(method)
+
+    msg, = exc_info.value.args
+    assert msg == f'eye_components must not be None if passing {column} to event detection'