Repository: aeye-lab/pymovements
Pull request #162 (2023-02-17 17:10:18)

Datasets: Add examples to GazeBase and JuDo1000
This adds a beginner example to GazeBase and JuDo1000.
Both `download` and `extract` are set to false, so no data will be downloaded.

The call to the `load()` method is marked to be skipped for testing.

codecov-commenter: # [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/162?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: **66.01**% // Increases project coverage by **`+0.51%`** :tada:
> Coverage data is based on head [(`0c12f4a`)](https://codecov.io/gh/aeye-lab/pymovements/pull/162?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) compared to base [(`493b897`)](https://codecov.io/gh/aeye-lab/pymovements/commit/493b897e44d498a951f1b648b364a7c9f61dd85d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).
> Patch coverage: 54.16% of modified lines in pull request are covered.

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


```diff
@@            Coverage Diff             @@
##             main     #162      +/-   ##
==========================================
+ Coverage   65.50%   66.01%   +0.51%     
==========================================
  Files          20       21       +1     
  Lines         658      668      +10     
  Branches      155      157       +2     
==========================================
+ Hits          431      441      +10     
  Misses        215      215              
  Partials       12       12              
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/162?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/datasets/public\_dataset.py](https://codecov.io/gh/aeye-lab/pymovements/pull/162?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL3B1YmxpY19kYXRhc2V0LnB5) | `47.50% <47.50%> (ø)` | |
| [src/pymovements/datasets/dataset.py](https://codecov.io/gh/aeye-lab/pymovements/pull/162?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2RhdGFzZXQucHk=) | `32.97% <75.00%> (ø)` | |
| [src/pymovements/datasets/\_\_init\_\_.py](https://codecov.io/gh/aeye-lab/pymovements/pull/162?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/datasets/gazebase.py](https://codecov.io/gh/aeye-lab/pymovements/pull/162?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2dhemViYXNlLnB5) | `100.00% <100.00%> (+7.69%)` | :arrow_up: |
| [src/pymovements/datasets/judo1000.py](https://codecov.io/gh/aeye-lab/pymovements/pull/162?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2p1ZG8xMDAwLnB5) | `100.00% <100.00%> (+7.69%)` | :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/162?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/datasets/__init__.py b/src/pymovements/datasets/__init__.py
index 35692df..98af9a3 100644
--- a/src/pymovements/datasets/__init__.py
+++ b/src/pymovements/datasets/__init__.py
@@ -18,7 +18,7 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """This module provides access to publicly available datasets."""
-from pymovements.datasets.base import Dataset  # noqa: F401
-from pymovements.datasets.base import PublicDataset  # noqa: F401
+from pymovements.datasets.dataset import Dataset  # noqa: F401
 from pymovements.datasets.gazebase import GazeBase  # noqa: F401
 from pymovements.datasets.judo1000 import JuDo1000  # noqa: F401
+from pymovements.datasets.public_dataset import PublicDataset  # noqa: F401
diff --git a/src/pymovements/datasets/base.py b/src/pymovements/datasets/dataset.py
similarity index 77%
rename from src/pymovements/datasets/base.py
rename to src/pymovements/datasets/dataset.py
index 03189f7..4c6a92b 100644
--- a/src/pymovements/datasets/base.py
+++ b/src/pymovements/datasets/dataset.py
@@ -17,21 +17,17 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-"""This module provides base dataset classes."""
+"""This module provides the base dataset class."""
 from __future__ import annotations
 
 import re
-from abc import ABCMeta
-from abc import abstractmethod
 from pathlib import Path
 from typing import Any
-from urllib.error import URLError
 
 import polars as pl
 from tqdm.auto import tqdm
 
 from pymovements.base import Experiment
-from pymovements.utils.downloads import download_and_extract_archive
 from pymovements.utils.paths import get_filepaths
 
 
@@ -111,7 +107,7 @@ class Dataset:
 
         # Get all filepaths that match regular expression.
         csv_filepaths = get_filepaths(
-            path=self.raw_dirpath,
+            path=self.raw_rootpath,
             regex=filename_regex,
         )
 
@@ -276,7 +272,7 @@ class Dataset:
                 )
 
     @property
-    def dirpath(self) -> Path:
+    def rootpath(self) -> Path:
         """Get the path to the dataset directory.
 
         The dataset path points to a directory in the specified root directory which is named the
@@ -287,111 +283,23 @@ class Dataset:
         >>> class CustomDataset(Dataset):
         ...     pass
         >>> dataset = CustomDataset(root='data')
-        >>> dataset.dirpath  # doctest: +SKIP
+        >>> dataset.rootpath  # doctest: +SKIP
         Path('data/CustomDataset')
         """
         return self.root / self.__class__.__name__
 
     @property
-    def raw_dirpath(self) -> Path:
+    def raw_rootpath(self) -> Path:
         """Get the path to the directory of the raw data.
 
-        The raw data directory path points to a directory named `raw` in the dataset `dirpath`.
+        The raw data directory path points to a directory named `raw` in the dataset `rootpath`.
 
         Example
         -------
         >>> class CustomDataset(Dataset):
         ...     pass
         >>> dataset = CustomDataset(root='data')
-        >>> dataset.raw_dirpath  # doctest: +SKIP
+        >>> dataset.raw_rootpath  # doctest: +SKIP
         Path('data/CustomDataset/raw')
         """
-        return self.dirpath / 'raw'
-
-
-class PublicDataset(Dataset, metaclass=ABCMeta):
-    """Extends the `Dataset` abstract base class with functionality for downloading in extracting.
-
-    To implement this abstract base class for a new dataset, the attributes/properties `_mirrors`
-    and `_resources` must be implemented.
-    """
-
-    def __init__(
-        self,
-        root: str,
-        download: bool = False,
-        remove_finished: bool = False,
-        **kwargs,
-    ):
-        super().__init__(root=root, **kwargs)
-        if download:
-            self.download(remove_finished=remove_finished)
-
-    def download(self, remove_finished: bool = False) -> None:
-        """Download dataset.
-
-        Parameters
-        ----------
-        remove_finished : bool
-            Remove archive files after extraction.
-
-        Raises
-        ------
-        RuntimeError
-            If downloading a resource failed for all given mirrors.
-        """
-        self.raw_dirpath.mkdir(parents=True, exist_ok=True)
-
-        for resource in self._resources:
-            success = False
-
-            for mirror in self._mirrors:
-
-                url = f'{mirror}{resource["resource"]}'
-
-                try:
-                    download_and_extract_archive(
-                        url=url,
-                        download_dirpath=self.dirpath,
-                        download_filename=resource['filename'],
-                        extract_dirpath=self.raw_dirpath,
-                        md5=resource['md5'],
-                        recursive=True,
-                        remove_finished=remove_finished,
-                    )
-                    success = True
-
-                except URLError as error:
-                    print(f'Failed to download (trying next):\n{error}')
-                    # downloading the resource, try next mirror
-                    continue
-
-                # downloading the resource was successful, we don't need to try another mirror
-                break
-
-            if not success:
-                raise RuntimeError(
-                    f"downloading resource {resource['resource']} failed for all mirrors.",
-                )
-
-    @property
-    @abstractmethod
-    def _mirrors(self):
-        """This attribute/property must provide a list of mirrors of the dataset.
-
-        Each entry should be of type `str` and end with a '/'.
-        """
-
-    @property
-    @abstractmethod
-    def _resources(self):
-        """This attribute must provide a list of dataset resources.
-
-        Each list entry should be a dictionary with the following keys:
-
-        - resource: The url suffix of the resource. This will be concatenated with the mirror.
-        - filename: The filename under which the file is saved as.
-        - md5: The MD5 checksum of the respective file.
-
-        All values should be of type string.
-        """
+        return self.rootpath / 'raw'
diff --git a/src/pymovements/datasets/gazebase.py b/src/pymovements/datasets/gazebase.py
index e04e42c..781b59a 100644
--- a/src/pymovements/datasets/gazebase.py
+++ b/src/pymovements/datasets/gazebase.py
@@ -19,7 +19,7 @@
 # SOFTWARE.
 """This module provides an interface to the GazeBase dataset."""
 from pymovements.base import Experiment
-from pymovements.datasets.base import PublicDataset
+from pymovements.datasets.public_dataset import PublicDataset
 
 
 class GazeBase(PublicDataset):
@@ -38,6 +38,16 @@ class GazeBase(PublicDataset):
     task (BLG).
 
     Check the respective paper for details :cite:p:`GazeBase`.
+
+    Change to ``download=True`` and `extract=True`` for downloading and extracting the dataset.
+
+    >>> dataset = GazeBase(
+    ...     root='data/',
+    ...     download=False,
+    ...     extract=False,
+    ...     remove_finished=False,
+    ... )
+    >>> dataset.load()  # doctest: +SKIP
     """
     _mirrors = [
         'https://figshare.com/ndownloader/files/',
diff --git a/src/pymovements/datasets/judo1000.py b/src/pymovements/datasets/judo1000.py
index b4b7439..9d859c0 100644
--- a/src/pymovements/datasets/judo1000.py
+++ b/src/pymovements/datasets/judo1000.py
@@ -19,7 +19,7 @@
 # SOFTWARE.
 """This module provides an interface to the JuDo1000 dataset."""
 from pymovements.base import Experiment
-from pymovements.datasets.base import PublicDataset
+from pymovements.datasets.public_dataset import PublicDataset
 
 
 class JuDo1000(PublicDataset):
@@ -32,6 +32,18 @@ class JuDo1000(PublicDataset):
     screen.
 
     Check the respective `repository <'https://osf.io/download/4wy7s/'>` for details.
+
+    Examples
+    --------
+    Change to ``download=True`` and `extract=True`` for downloading and extracting the dataset.
+
+    >>> dataset = JuDo1000(
+    ...     root='data/',
+    ...     download=False,
+    ...     extract=False,
+    ...     remove_finished=False,
+    ... )
+    >>> dataset.load()  # doctest: +SKIP
     """
     _mirrors = [
         'https://osf.io/download/',
diff --git a/src/pymovements/datasets/public_dataset.py b/src/pymovements/datasets/public_dataset.py
new file mode 100644
index 0000000..dac3f44
--- /dev/null
+++ b/src/pymovements/datasets/public_dataset.py
@@ -0,0 +1,145 @@
+# Copyright (c) 2023-2023 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""This module provides the abstract base public dataset class."""
+from __future__ import annotations
+
+from abc import ABCMeta
+from abc import abstractmethod
+from urllib.error import URLError
+
+from pymovements.datasets.dataset import Dataset
+from pymovements.utils.archives import extract_archive
+from pymovements.utils.downloads import download_file
+
+
+class PublicDataset(Dataset, metaclass=ABCMeta):
+    """Extends the `Dataset` base class with functionality for downloading and extracting public
+    datasets.
+
+    To implement this abstract base class for a new dataset, the attributes/properties `_mirrors`
+    and `_resources` must be implemented.
+    """
+
+    def __init__(
+        self,
+        root: str,
+        download: bool = False,
+        extract: bool = False,
+        remove_finished: bool = False,
+        **kwargs,
+    ):
+        """Initialize dataset.
+
+        If desired, dataset resources will be downloaded and extracted.
+
+        Parameters
+        ----------
+        download : bool
+            Download all dataset resources.
+        extract : bool
+            Extract dataset archive files.
+        remove_finished : bool
+            Remove archive files after extraction.
+        """
+        super().__init__(root=root, **kwargs)
+        if download:
+            self.download()
+
+        if extract:
+            self.extract(remove_finished=remove_finished)
+
+    def download(self) -> None:
+        """Download dataset.
+
+        Raises
+        ------
+        RuntimeError
+            If downloading a resource failed for all given mirrors.
+        """
+        self.raw_rootpath.mkdir(parents=True, exist_ok=True)
+
+        for resource in self._resources:
+            success = False
+
+            for mirror in self._mirrors:
+
+                url = f'{mirror}{resource["resource"]}'
+
+                try:
+                    download_file(
+                        url=url,
+                        dirpath=self.rootpath,
+                        filename=resource['filename'],
+                        md5=resource['md5'],
+                    )
+                    success = True
+
+                except URLError as error:
+                    print(f'Failed to download (trying next):\n{error}')
+                    # downloading the resource, try next mirror
+                    continue
+
+                # downloading the resource was successful, we don't need to try another mirror
+                break
+
+            if not success:
+                raise RuntimeError(
+                    f"downloading resource {resource['resource']} failed for all mirrors.",
+                )
+
+    def extract(self, remove_finished: bool = False) -> None:
+        """Extract dataset archives.
+
+        Parameters
+        ----------
+        remove_finished : bool
+            Remove archive files after extraction.
+        """
+        self.raw_rootpath.mkdir(parents=True, exist_ok=True)
+
+        for resource in self._resources:
+            extract_archive(
+                source_path=self.rootpath / resource['filename'],
+                destination_path=self.raw_rootpath,
+                recursive=True,
+                remove_finished=remove_finished,
+            )
+
+    @property
+    @abstractmethod
+    def _mirrors(self):
+        """This attribute/property must provide a list of mirrors of the dataset.
+
+        Each entry should be of type `str` and end with a '/'.
+        """
+
+    @property
+    @abstractmethod
+    def _resources(self):
+        """This attribute must provide a list of dataset resources.
+
+        Each list entry should be a dictionary with the following keys:
+
+        - resource: The url suffix of the resource. This will be concatenated with the mirror.
+        - filename: The filename under which the file is saved as.
+        - md5: The MD5 checksum of the respective file.
+
+        All values should be of type string.
+        """
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)