Repository: aeye-lab/pymovements
Pull request #152 (2023-02-16 09:26:16)

Events: Implement common event detection interface
All event detection methods now have both positions and velocities parameters, independent of their actual usage.

This change also includes adding a new check that can be used across modules.

This change is the prerequisite for two things:
* a more straight-forward implementation in the coming `detect_events()` method in the `Dataset` class
* a greater range of properties to be included in the event dataframe

codecov-commenter: # [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/152?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Base: **65.50**% // Head: **65.60**% // Increases project coverage by **`+0.10%`** :tada:
> Coverage data is based on head [(`951fd35`)](https://codecov.io/gh/aeye-lab/pymovements/pull/152?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) compared to base [(`2b69731`)](https://codecov.io/gh/aeye-lab/pymovements/commit/2b69731f8c4c7c681f17e6ecf6f74298cd10c1e1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).
> Patch coverage: 100.00% of modified lines in pull request are covered.

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


```diff
@@            Coverage Diff             @@
##             main     #152      +/-   ##
==========================================
+ Coverage   65.50%   65.60%   +0.10%     
==========================================
  Files          20       20              
  Lines         658      660       +2     
  Branches      155      151       -4     
==========================================
+ Hits          431      433       +2     
  Misses        215      215              
  Partials       12       12              
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/152?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/engbert.py](https://codecov.io/gh/aeye-lab/pymovements/pull/152?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9lbmdiZXJ0LnB5) | `95.23% <100.00%> (+0.50%)` | :arrow_up: |
| [src/pymovements/events/idt.py](https://codecov.io/gh/aeye-lab/pymovements/pull/152?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9pZHQucHk=) | `93.54% <100.00%> (-0.21%)` | :arrow_down: |
| [src/pymovements/events/ivt.py](https://codecov.io/gh/aeye-lab/pymovements/pull/152?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9pdnQucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/utils/checks.py](https://codecov.io/gh/aeye-lab/pymovements/pull/152?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL2NoZWNrcy5weQ==) | `83.33% <100.00%> (+6.86%)` | :arrow_up: |

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 at Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/152?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/README.md b/README.md
index 191ec41..9b5f5bd 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
 ---
 
 [![PyPI Latest Release](https://img.shields.io/pypi/v/pymovements.svg)](https://pypi.python.org/pypi/pymovements/)
+[![Conda Latest Release](https://img.shields.io/conda/vn/conda-forge/pymovements)](https://anaconda.org/conda-forge/pymovements)
 [![PyPI status](https://img.shields.io/pypi/status/pymovements.svg)](https://pypi.python.org/pypi/pymovements/)
 [![Python version](https://img.shields.io/pypi/pyversions/pymovements.svg)](https://pypi.python.org/pypi/pymovements/)
 ![Operating System](https://img.shields.io/badge/os-linux%20%7C%20macOS%20%7C%20windows-blue)
@@ -28,16 +29,38 @@ and render plots to visually analyze your results.
 - **Contributing:** https://github.com/aeye-lab/pymovements/blob/main/CONTRIBUTING.md
 - **Bug reports:** https://github.com/aeye-lab/pymovements/issues
 - **PyPI package:** https://pypi.org/project/pymovements
+- **Conda package:** https://anaconda.org/conda-forge/pymovements
 
 
 ## Installation
 
+#### Using pip
+
 pymovements can be installed directly from the PyPI repositories:
 
 ```bash
 pip install pymovements
 ```
 
+#### Using conda
+
+pymovements can be installed from the conda-forge repositories.
+
+If not already done, you will need to add conda-forge to your available channels:
+
+```bash
+conda config --add channels conda-forge
+conda config --set channel_priority strict
+```
+
+You can then install pymovements into you conda environment:
+
+```bash
+conda install -c conda-forge pymovements
+```
+
+#### Development installation
+
 To use the latest development version or to try out tutorials, pymovements may be alternatively
 cloned and installed with
 
diff --git a/setup.cfg b/setup.cfg
index b25e04b..90f4f42 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = pymovements
-version = 0.2.0
+version = 0.2.1
 url = https://github.com/aeye-lab/pymovements
 project_urls =
     Bug Tracker = https://github.com/aeye-lab/pymovements/issues
@@ -37,6 +37,7 @@ install_requires =
     pyarrow>=4.0.0
     scipy>=1.5.4
     tqdm>=4.0.0
+    typing_extensions>=4.0.0
 python_requires = >=3.7
 packages = find:
 package_dir =
diff --git a/src/pymovements/events/__init__.py b/src/pymovements/events/__init__.py
index 88a0716..8be5d72 100644
--- a/src/pymovements/events/__init__.py
+++ b/src/pymovements/events/__init__.py
@@ -20,9 +20,10 @@
 """
 This module holds all event related functionality.
 """
-from pymovements.events.base import Event  # noqa: F401
-from pymovements.events.base import Fixation  # noqa: F401
-from pymovements.events.base import Saccade  # noqa: F401
 from pymovements.events.engbert import microsaccades  # noqa: F401
+from pymovements.events.events import Event  # noqa: F401
+from pymovements.events.events import EventDetectionCallable  # noqa: F401
+from pymovements.events.events import Fixation  # noqa: F401
+from pymovements.events.events import Saccade  # noqa: F401
 from pymovements.events.idt import idt  # noqa: F401
 from pymovements.events.ivt import ivt  # noqa: F401
diff --git a/src/pymovements/events/engbert.py b/src/pymovements/events/engbert.py
index 320320a..6b13fff 100644
--- a/src/pymovements/events/engbert.py
+++ b/src/pymovements/events/engbert.py
@@ -28,10 +28,12 @@ import numpy as np
 import polars as pl
 
 from pymovements.transforms import consecutive
+from pymovements.utils.checks import check_shapes_positions_velocities
 
 
 def microsaccades(
-    velocities: np.ndarray,
+    positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+    velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
     threshold: np.ndarray | tuple[float] | str = 'engbert2015',
     threshold_factor: float = 6,
     minimum_duration: int = 6,
@@ -48,6 +50,8 @@ def microsaccades(
 
     Parameters
     ----------
+    positions : np.ndarray, shape (N, 2)
+        x and y positions of N samples in chronological order
     velocities : np.ndarray, shape (N, 2)
         x and y velocities of N samples in chronological order
     threshold : np.ndarray, tuple[float, float] or str
@@ -73,6 +77,11 @@ def microsaccades(
         If `threshold` value is below `min_threshold` value.
         If passed `threshold` is either not two-dimensional or not a supported method.
     """
+    positions = np.array(positions)
+    velocities = np.array(velocities)
+
+    check_shapes_positions_velocities(positions=positions, velocities=velocities)
+
     if isinstance(threshold, str):
         threshold = compute_threshold(velocities, method=threshold)
     else:
diff --git a/src/pymovements/events/base.py b/src/pymovements/events/events.py
similarity index 81%
rename from src/pymovements/events/base.py
rename to src/pymovements/events/events.py
index 5a34a28..80869e6 100644
--- a/src/pymovements/events/base.py
+++ b/src/pymovements/events/events.py
@@ -22,9 +22,40 @@ This module holds all the main Event classes used for event detection.
 """
 from __future__ import annotations
 
+import numpy as np
+import polars as pl
+from typing_extensions import Protocol
+
 from pymovements.utils.decorators import auto_str
 
 
+class EventDetectionCallable(Protocol):
+    """Minimal interface to be implemented by all event detection methods."""
+
+    def __call__(
+            self,
+            positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+            velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+            **kwargs,
+    ) -> pl.DataFrame:
+        """Minimal interface to be implemented by all event detection methods.
+
+        Parameters
+        ----------
+        positions: array-like, shape (N, 2)
+            Continuous 2D position time series
+        velocities: array-like, shape (N, 2)
+            Corresponding continuous 2D velocity time series.
+        **kwargs:
+            Additional keyword arguments for the specific event detection method.
+
+        Returns
+        -------
+        pl.DataFrame
+            A dataframe with detected events as rows.
+        """
+
+
 @auto_str
 class Event:
     """
diff --git a/src/pymovements/events/idt.py b/src/pymovements/events/idt.py
index d81bd2f..d49b0b7 100644
--- a/src/pymovements/events/idt.py
+++ b/src/pymovements/events/idt.py
@@ -25,6 +25,8 @@ from __future__ import annotations
 import numpy as np
 import polars as pl
 
+from pymovements.utils.checks import check_shapes_positions_velocities
+
 
 def dispersion(positions: list[list[float]] | np.ndarray) -> float:
     """
@@ -47,7 +49,8 @@ def dispersion(positions: list[list[float]] | np.ndarray) -> float:
 
 
 def idt(
-        positions: list[list[float]] | np.ndarray,
+        positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+        velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
         dispersion_threshold: float,
         minimum_duration: int,
 ) -> pl.DataFrame:
@@ -67,6 +70,8 @@ def idt(
     ----------
     positions: array-like, shape (N, 2)
         Continuous 2D position time series
+    velocities: array-like, shape (N, 2)
+        Corresponding continuous 2D velocity time series.
     dispersion_threshold: float
         Threshold for dispersion for a group of consecutive samples to be identified as fixation
     minimum_duration: int
@@ -84,24 +89,14 @@ def idt(
         If dispersion_threshold is not greater than 0
         If duration_threshold is not greater than 0
     """
-
     positions = np.array(positions)
+    velocities = np.array(velocities)
 
-    # make sure positions is 2d
-    if positions.ndim != 2:
-        raise ValueError('positions needs to have shape (N, 2)')
-
-    # make sure positions has shape (N, 2)
-    if positions.shape[1] != 2:
-        raise ValueError('positions needs to have shape (N, 2)')
-
-    # Check if dispersion_threshold is greater 0
+    check_shapes_positions_velocities(positions=positions, velocities=velocities)
     if dispersion_threshold <= 0:
         raise ValueError('dispersion threshold must be greater than 0')
-
-    # Check if duration_threshold is greater 0
     if minimum_duration <= 0:
-        raise ValueError('duration threshold must be greater than 0')
+        raise ValueError('minimum duration must be greater than 0')
 
     fixations = []
 
diff --git a/src/pymovements/events/ivt.py b/src/pymovements/events/ivt.py
index 51879c6..674b12d 100644
--- a/src/pymovements/events/ivt.py
+++ b/src/pymovements/events/ivt.py
@@ -27,11 +27,12 @@ import polars as pl
 
 from pymovements.transforms import consecutive
 from pymovements.transforms import norm
+from pymovements.utils.checks import check_shapes_positions_velocities
 
 
 def ivt(
-        positions: list[list[float]] | np.ndarray,
-        velocities: list[list[float]] | np.ndarray,
+        positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+        velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
         velocity_threshold: float,
         minimum_duration: int,
 ) -> pl.DataFrame:
@@ -74,37 +75,14 @@ def ivt(
     positions = np.array(positions)
     velocities = np.array(velocities)
 
-    # make sure positions and velocities have shape (n, 2)
-    if positions.ndim != 2:
-        raise ValueError('positions need to have shape (N, 2)')
-
-    if positions.shape[1] != 2:
-        raise ValueError('positions need to have shape (N, 2)')
-
-    if velocities.ndim != 2:
-        raise ValueError('velocities need to have shape (N, 2)')
-
-    if velocities.shape[1] != 2:
-        raise ValueError('velocities need to have shape (N, 2)')
-
-    # Check matching shape for positions and velocities
-    if positions.shape != velocities.shape:
-        raise ValueError(
-            f"shape of positions {positions.shape} doesn't match"
-            f'shape of velocities {velocities.shape}',
-        )
-
-    # Check if threshold is None
+    check_shapes_positions_velocities(positions=positions, velocities=velocities)
     if velocity_threshold is None:
-        raise ValueError('velocity threshold is None')
-
-    # Check if threshold is greater 0
+        raise ValueError('velocity threshold must not be None')
     if velocity_threshold <= 0:
         raise ValueError('velocity threshold must be greater than 0')
 
+    # Get all indices with norm-velocities below threshold.
     velocity_norm = norm(velocities, axis=1)
-
-    # Get all indices with velocities outside of ellipse.
     below_threshold_indices = np.where(velocity_norm < velocity_threshold)[0]
 
     # Get all fixation candidates by grouping all consecutive indices.
diff --git a/src/pymovements/utils/checks.py b/src/pymovements/utils/checks.py
index 6e20869..9f63a2a 100644
--- a/src/pymovements/utils/checks.py
+++ b/src/pymovements/utils/checks.py
@@ -25,7 +25,7 @@ from typing import Any
 import numpy as np
 
 
-def check_no_zeros(variable: Any, name: str = 'variable'):
+def check_no_zeros(variable: Any, name: str = 'variable') -> None:
     """
     Check if variable, or if it is iterable, any of its components are zero.
     """
@@ -50,7 +50,7 @@ def check_no_zeros(variable: Any, name: str = 'variable'):
                 raise ValueError(error_message)
 
 
-def check_nan_both_channels(arr: np.ndarray):
+def check_nan_both_channels(arr: np.ndarray) -> None:
     """
     Checks if all nans occur at the same time steps for both channels.
     """
@@ -59,5 +59,35 @@ def check_nan_both_channels(arr: np.ndarray):
     # one coordinate)
     if not np.array_equal(np.isnan(arr[:, 0]), np.isnan(arr[:, 1])):
         raise ValueError(
-            'nans have to occur at the same steps of horizontal and vertical direction',
+            'nans must occur at the same steps of horizontal and vertical direction',
+        )
+
+
+def check_shapes_positions_velocities(positions: np.ndarray, velocities: np.ndarray) -> None:
+    """Checks if positions and velocities are of shape ``(N, 2)`` and shape is equal for both.
+
+    Parameters
+    ----------
+    positions : np.ndarray
+        The positions array.
+    velocities : np.ndarray
+        The velocities array.
+
+    Raises
+    ------
+    ValueError
+        If positions or velocities are not of shape ``(N, 2)`` or the shape is not equal for both.
+
+    """
+    # make sure positions and velocities have shape (N, 2)
+    if positions.ndim != 2 or positions.shape[1] != 2:
+        raise ValueError(f'positions must have shape (N, 2) but have shape ({positions.shape})')
+    if velocities.ndim != 2 or velocities.shape[1] != 2:
+        raise ValueError(f'velocities must have shape (N, 2) but have shape ({velocities.shape})')
+
+    # Check matching shape for positions and velocities
+    if positions.shape != velocities.shape:
+        raise ValueError(
+            f"shape of positions {positions.shape} doesn't match"
+            f'shape of velocities {velocities.shape}',
         )


Test patch:
diff --git a/tests/events/test_engbert.py b/tests/events/test_engbert.py
index 2ab9b58..fc52019 100644
--- a/tests/events/test_engbert.py
+++ b/tests/events/test_engbert.py
@@ -32,17 +32,29 @@ from pymovements.synthetic import step_function
     'kwargs, expected',
     [
         pytest.param(
-            {'velocities': np.random.uniform(size=(10, 2)), 'threshold': (1, 1, 1)},
+            {
+                'positions': np.zeros(shape=(10, 2)),
+                'velocities': np.zeros(shape=(10, 2)),
+                'threshold': (1, 1, 1),
+            },
             ValueError,
             id='non_2d_tuple_threshold_raise_value_error',
         ),
         pytest.param(
-            {'velocities': np.random.uniform(size=(10, 2)), 'threshold': (0, 100)},
+            {
+                'positions': np.zeros(shape=(10, 2)),
+                'velocities': np.random.uniform(size=(10, 2)),
+                'threshold': (0, 100),
+            },
             ValueError,
             id='low_variance_yaw_threshold_raise_runtime_error',
         ),
         pytest.param(
-            {'velocities': np.random.uniform(size=(10, 2)), 'threshold': (100, 0)},
+            {
+                'positions': np.zeros(shape=(10, 2)),
+                'velocities': np.random.uniform(size=(10, 2)),
+                'threshold': (100, 0),
+            },
             ValueError,
             id='low_variance_pitch_threshold_raise_runtime_error',
         ),
@@ -58,6 +70,7 @@ def test_microsaccades_raises_error(kwargs, expected):
     [
         pytest.param(
             {
+                'positions': step_function(length=100, steps=[0], values=[(0, 0)]),
                 'velocities': step_function(
                     length=100,
                     steps=[40, 50],
@@ -77,6 +90,7 @@ def test_microsaccades_raises_error(kwargs, expected):
         ),
         pytest.param(
             {
+                'positions': step_function(length=100, steps=[0], values=[(0, 0)]),
                 'velocities': step_function(
                     length=100,
                     steps=[20, 30, 70, 80],
diff --git a/tests/events/test_idt.py b/tests/events/test_idt.py
index 2f38fda..d6cc12a 100644
--- a/tests/events/test_idt.py
+++ b/tests/events/test_idt.py
@@ -24,6 +24,7 @@ from polars.testing import assert_frame_equal
 
 from pymovements.events.idt import idt
 from pymovements.synthetic import step_function
+from pymovements.transforms import pos2vel
 
 
 @pytest.mark.parametrize(
@@ -32,6 +33,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': None,
+                'velocities': None,
                 'dispersion_threshold': 1,
                 'minimum_duration': 1,
             },
@@ -41,6 +43,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': [[1, 2], [1, 2]],
+                'velocities': [[1, 2], [1, 2]],
                 'dispersion_threshold': None,
                 'minimum_duration': 1,
             },
@@ -50,6 +53,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': [[1, 2], [1, 2]],
+                'velocities': [[1, 2], [1, 2]],
                 'dispersion_threshold': 1,
                 'minimum_duration': None,
             },
@@ -59,6 +63,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': 1,
+                'velocities': 1,
                 'dispersion_threshold': 1,
                 'minimum_duration': 1,
             },
@@ -68,6 +73,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': [1, 2, 3],
+                'velocities': [1, 2, 3],
                 'dispersion_threshold': 1,
                 'minimum_duration': 1,
             },
@@ -77,6 +83,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': [[1, 2, 3], [1, 2, 3]],
+                'velocities': [[1, 2, 3], [1, 2, 3]],
                 'dispersion_threshold': 1,
                 'minimum_duration': 1,
             },
@@ -86,6 +93,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': [[1, 2], [1, 2]],
+                'velocities': [[1, 2], [1, 2]],
                 'dispersion_threshold': 0,
                 'minimum_duration': 1,
             },
@@ -95,6 +103,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': [[1, 2], [1, 2]],
+                'velocities': [[1, 2], [1, 2]],
                 'dispersion_threshold': 1,
                 'minimum_duration': 0,
             },
@@ -104,6 +113,7 @@ from pymovements.synthetic import step_function
         pytest.param(
             {
                 'positions': [[1, 2], [1, 2]],
+                'velocities': [[1, 2], [1, 2]],
                 'dispersion_threshold': 1,
                 'minimum_duration': 1.0,
             },
@@ -161,6 +171,7 @@ def test_idt_raises_error(kwargs, expected_error):
     ],
 )
 def test_idt_detects_fixations(kwargs, expected):
-    events = idt(**kwargs)
+    velocities = pos2vel(kwargs['positions'], sampling_rate=10, method='preceding')
+    events = idt(velocities=velocities, **kwargs)
 
     assert_frame_equal(events, expected)