Repository: aeye-lab/pymovements
Pull request #198 (2023-02-23 14:35:40)

Events: added support for np.nan
## Implemented changes

- [ ] added np.nan support for the event detection algos (added np.nanmean, np.nanmax)
- [ ] created test-cases for np.nan in position and velocities

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change is or requires a documentation update

## How Has This Been Tested?

- [ ] added test-cases

## 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
- [ ] I have made corresponding changes to the documentation
- [ ] 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
- [ ] Any dependent changes have been merged and published in downstream modules
- [ ] I have checked my code and corrected any misspellings



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 51ed9fe..59383c8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,3 +1,6 @@
+ci:
+    skip: [pylint]
+
 repos:
 -   repo: https://github.com/asottile/add-trailing-comma
     rev: v2.4.0
@@ -15,19 +18,33 @@ repos:
     -   id: reorder-python-imports
         args: [--application-directories=src]
 -   repo: https://github.com/pappasam/toml-sort
-    rev: v0.22.3
+    rev: v0.22.4
     hooks:
     -   id: toml-sort-fix
 -   repo: https://github.com/dannysepler/rm_unneeded_f_str
     rev: v0.2.0
     hooks:
     -   id: rm-unneeded-f-str
+-   repo: https://github.com/kynan/nbstripout
+    rev: 0.6.0
+    hooks:
+    -   id: nbstripout
 -   repo: https://github.com/Lucas-C/pre-commit-hooks
     rev: v1.4.2
     hooks:
     -   id: insert-license
         args: [--use-current-year]
-        exclude_types: [bib, css, image, json, markdown, rst, toml, yaml]
+        exclude_types: [bib, css, image, json, jupyter, markdown, rst, toml, yaml]
+-   repo: https://github.com/nbQA-dev/nbQA
+    rev: 1.6.3
+    hooks:
+    -   id: nbqa-autopep8
+    -   id: nbqa-flake8
+        args: ['--builtins=display']
+    -   id: nbqa-isort
+        args: ["--float-to-top"]
+    -   id: nbqa-pyupgrade
+        args: ["--py37-plus"]
 -   repo: https://github.com/pre-commit/mirrors-mypy
     rev: v1.0.1
     hooks:
@@ -65,3 +82,29 @@ repos:
     rev: 6.0.0
     hooks:
     -   id: flake8
+- repo: https://github.com/PyCQA/pylint
+  rev: v2.16.2
+  hooks:
+    - id: pylint
+      name: pylint
+      entry: pylint
+      language: system
+      types: [python]
+      files: ^src/
+      args:
+        [
+          '--output-format=parseable',
+          '--rcfile=pylintrc',
+        ]
+    - id: pylint
+      name: pylint-tests-directory
+      entry: pylint
+      language: system
+      types: [python]
+      files: ^tests/
+      args:
+        [
+          '--output-format=parseable',
+          '--disable=missing-class-docstring,missing-function-docstring,protected-access',
+          '--rcfile=pylintrc',
+        ]
diff --git a/README.md b/README.md
index 9b5f5bd..f4406c6 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
 [![License](https://img.shields.io/pypi/l/pymovements.svg)](https://github.com/aeye-lab/pymovements/blob/master/LICENSE.txt)
 [![Test Status](https://img.shields.io/github/actions/workflow/status/aeye-lab/pymovements/tests.yml?label=tests)](https://github.com/aeye-lab/pymovements/actions/workflows/tests.yml)
 [![Documentation Status](https://readthedocs.org/projects/pymovements/badge/?version=latest)](https://pymovements.readthedocs.io/en/latest/?badge=latest)
-[![codecov](https://codecov.io/github/aeye-lab/pymovements/branch/main/graph/badge.svg?token=QY3NDHAT2C)](https://codecov.io/github/aeye-lab/pymovements)
+[![codecov](https://codecov.io/github/aeye-lab/pymovements/branch/main/graph/badge.svg?token=QY3NDHAT2C)](https://app.codecov.io/gh/aeye-lab/pymovements)
 [![PyPI downloads/month](https://img.shields.io/pypi/dm/pymovements.svg)](https://pypistats.org/packages/pymovements)
 
 
diff --git a/docs/source/conf.py b/docs/source/conf.py
index e18a253..4d8dffe 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -137,11 +137,15 @@ html_theme_options = {
         },
     ],
     'logo': {
-        'image_light': 'logo.svg',
-        'image_dark': 'logo.svg',
+        'image_light': 'https://raw.githubusercontent.com/aeye-lab/pymovements/main/docs/source/_static/logo.svg',  # noqa: E501
+        'image_dark': 'https://raw.githubusercontent.com/aeye-lab/pymovements/main/docs/source/_static/logo.svg',  # noqa: E501
     },
 }
 
+# -- Options for juypter notebooks
+
+nbsphinx_execute = 'never'
+
 
 # -- Options for BibTeX ------------------------------------------------------
 
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ed77811..595a079 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -9,6 +9,6 @@ Welcome to the pymovements documentation!
    :maxdepth: 2
 
    getting-started
-   tutorial/index
+   tutorials/index
    reference/index
    bibliography
diff --git a/docs/source/tutorials/dataset.ipynb b/docs/source/tutorials/dataset.ipynb
new file mode 100644
index 0000000..658dfca
--- /dev/null
+++ b/docs/source/tutorials/dataset.ipynb
@@ -0,0 +1,173 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "011263bd",
+   "metadata": {},
+   "source": [
+    "# Working with Datasets"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "375b5f97",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pymovements.datasets import JuDo1000\n",
+    "from pymovements.events import ivt, microsaccades\n",
+    "\n",
+    "dataset = JuDo1000(\n",
+    "    root='data/',\n",
+    "    download=True,\n",
+    "    extract=True,\n",
+    "    remove_finished=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9585a173",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "subset = {\n",
+    "    'subject_id': [11, 22, 33, 77],\n",
+    "    'session_id': [1, 2],\n",
+    "}\n",
+    "dataset.load(events=False, preprocessed=False, subset=subset)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ff93298e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.fileinfo"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1cbab15d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.gaze[0]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "edd59432",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.pix2deg()\n",
+    "\n",
+    "dataset.gaze[0]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4590b9fd",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.pos2vel(method='savitzky_golay', window_length=7, polyorder=2)\n",
+    "\n",
+    "dataset.gaze[0]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a2e9a9bf",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.detect_events(method=microsaccades)\n",
+    "\n",
+    "dataset.events[0]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "aef13b52",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.detect_events(method=ivt, velocity_threshold=45, minimum_duration=55)\n",
+    "\n",
+    "dataset.events[0]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1b00ef41",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.save()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "821d490c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "del dataset"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f92137a0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset = JuDo1000(\n",
+    "    root='data/',\n",
+    "    download=False,\n",
+    "    extract=False,\n",
+    "    remove_finished=False,\n",
+    ")\n",
+    "\n",
+    "dataset.load(events=True, preprocessed=True, subset=subset)\n",
+    "\n",
+    "display(dataset.gaze[0])\n",
+    "display(dataset.events[0])"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.9"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/source/tutorial/index.rst b/docs/source/tutorials/index.rst
similarity index 93%
rename from docs/source/tutorial/index.rst
rename to docs/source/tutorials/index.rst
index 728cae1..530c4d7 100644
--- a/docs/source/tutorial/index.rst
+++ b/docs/source/tutorials/index.rst
@@ -8,3 +8,5 @@ These How-Tos provide detailed guides to implement the most important use cases
 
 .. toctree::
     :maxdepth: 1
+
+    dataset
diff --git a/pyproject.toml b/pyproject.toml
index 71088e0..ff71cf9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,7 @@ docs = [
   "pandoc",
   "pybtex",
   "pydata-sphinx-theme>=0.12",
-  "sphinx>=6.0.0",
+  "sphinx>=5.3.0",
   "sphinx-copybutton>=0.4.0",
   "sphinx_mdinclude>=0.5.0",
   "sphinxcontrib.datatemplates>=0.9.0",
diff --git a/src/pymovements/datasets/dataset.py b/src/pymovements/datasets/dataset.py
index f9d8529..d3da437 100644
--- a/src/pymovements/datasets/dataset.py
+++ b/src/pymovements/datasets/dataset.py
@@ -35,17 +35,29 @@ from pymovements.utils.paths import get_filepaths
 
 class Dataset:
     """Dataset base class."""
+    # pylint: disable=too-many-instance-attributes
+    # The Dataset class is exceptionally complex and needs many attributes.
 
     def __init__(
-        self,
-        root: str | Path,
-        experiment: Experiment | None = None,
-        filename_regex: str = '.*',
-        filename_regex_dtypes: dict[str, type] | None = None,
-        custom_read_kwargs: dict[str, Any] | None = None,
+            self,
+            root: str | Path,
+            experiment: Experiment | None = None,
+            filename_regex: str = '.*',
+            filename_regex_dtypes: dict[str, type] | None = None,
+            custom_read_kwargs: dict[str, Any] | None = None,
+            dataset_dirname: str = '.',
+            raw_dirname: str = 'raw',
+            preprocessed_dirname: str = 'preprocessed',
+            events_dirname: str = 'events',
     ):
         """Initialize the dataset object.
 
+        You can set up a custom directory structure by populating the particular dirname attributes.
+        See :py:attr:`~pymovements.dataset.Dataset.dataset_dirname`,
+        :py:attr:`~pymovements.dataset.Dataset.raw_dirname`,
+        :py:attr:`~pymovements.dataset.Dataset.preprocessed_dirname` and
+        :py:attr:`~pymovements.dataset.Dataset.events_dirname` for details.
+
         Parameters
         ----------
         root : str, Path
@@ -60,15 +72,37 @@ class Dataset:
             specific named groups to a particular datatype.
         custom_read_kwargs : dict[str, Any], optional
             If specified, these keyword arguments will be passed to the file reading function.
+        dataset_dirname : str, optional
+            Dataset directory name under root path. Can be `.` if dataset is located in root path.
+            Default: `.`
+        raw_dirname ; str, optional
+            Name of directory under dataset path that contains raw data. Can be `.` if raw data is
+            located in dataset path. We advise the user to keep the original raw data separate from
+            the preprocessed / event data. Default: `raw`
+        preprocessed_dirname : str, optional
+            Name of directory under dataset path that will be used to store preprocessed data. We
+            advise the user to keep the preprocessed data separate from the original raw data.
+            Default: `preprocessed`
+        events_dirname : str, optional
+            Name of directory under dataset path that will be used to store event data. We advise
+            the user to keep the event data separate from the original raw data. Default: `events`
         """
         self.fileinfo: pl.DataFrame = pl.DataFrame()
         self.gaze: list[pl.DataFrame] = []
         self.events: list[pl.DataFrame] = []
 
-        self.root = Path(root)
+        self._root = Path(root)
+        self.dataset_dirname = dataset_dirname
+        self.raw_dirname = raw_dirname
+        self.preprocessed_dirname = preprocessed_dirname
+        self.events_dirname = events_dirname
 
         self.experiment = experiment
 
+        if filename_regex is None:
+            raise ValueError('filename_regex must not be None')
+        if not isinstance(filename_regex, str):
+            raise TypeError('filename_regex must be of type str')
         self._filename_regex = filename_regex
 
         if filename_regex_dtypes is None:
@@ -84,6 +118,8 @@ class Dataset:
             events: bool = False,
             preprocessed: bool = False,
             subset: None | dict[str, float | int | str | list[float | int | str]] = None,
+            events_dirname: str | None = None,
+            preprocessed_dirname: str | None = None,
     ):
         """Parse file information and load all gaze files.
 
@@ -97,16 +133,28 @@ class Dataset:
             If specified, load only a subset of the dataset. All keys in the dictionary must be
             present in the fileinfo dataframe inferred by `infer_fileinfo()`. Values can be either
             float, int , str or a list of these.
+        events_dirname : str
+            One-time usage of an alternative directory name to save data relative to
+            :py:meth:`pymovements.Dataset.path`.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.events_rootpath`.
+        preprocessed_dirname : str
+            One-time usage of an alternative directory name to save data relative to
+            :py:meth:`pymovements.Dataset.path`.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.preprocessed_rootpath`.
 
         The parsed file information is assigned to the `fileinfo` attribute.
         All gaze files will be loaded as dataframes and assigned to the `gaze` attribute.
         """
-        self.fileinfo = self.infer_fileinfo()
-        self.take_subset(subset=subset)
-        self.gaze = self.load_gaze_files(preprocessed=preprocessed)
+        fileinfo = self.infer_fileinfo()
+        self.fileinfo = self.take_subset(fileinfo=fileinfo, subset=subset)
+        self.gaze = self.load_gaze_files(
+            preprocessed=preprocessed, preprocessed_dirname=preprocessed_dirname,
+        )
 
         if events:
-            self.events = self.load_event_files()
+            self.events = self.load_event_files(events_dirname=events_dirname)
 
     def infer_fileinfo(self) -> pl.DataFrame:
         """Infer information from filepaths and filenames.
@@ -121,12 +169,9 @@ class Dataset:
         AttributeError
             If no regular expression for parsing filenames is defined.
         RuntimeError
-            If an error occured during matching filenames.
+            If an error occurred during matching filenames or no files have been found.
         """
-        if self._filename_regex is not None:
-            filename_regex = re.compile(self._filename_regex)
-        else:
-            raise AttributeError('no regular expression for filenames is defined.')
+        filename_regex = re.compile(self._filename_regex)
 
         # Get all filepaths that match regular expression.
         csv_filepaths = get_filepaths(
@@ -135,47 +180,62 @@ class Dataset:
         )
 
         # Parse fileinfo from filenames.
-        fileinfo_records: list[dict[str, Any]] = []
+        fileinfo_dicts: list[dict[str, Any]] = []
         for filepath in csv_filepaths:
 
             # All csv_filepaths already match the filename_regex.
             match = filename_regex.match(filepath.name)
 
-            # This actually should never happen but mypy will complain otherwise.
+            # This should never happen but mypy will complain otherwise.
             if match is None:
                 raise RuntimeError(
-                    f'file {filepath} did not match regular expression {self._filename_regex}',
+                    f'file {filepath} did not match regular expression {filename_regex}',
                 )
 
             # We use the groupdict of the match as a base and add the filepath.
-            fileinfo_record = match.groupdict()
+            fileinfo_dict = match.groupdict()
 
-            for fileinfo_key, fileinfo_dtype in self._filename_regex_dtypes.items():
-                fileinfo_record[fileinfo_key] = fileinfo_dtype(fileinfo_record[fileinfo_key])
+            fileinfo_dict['filepath'] = str(filepath.relative_to(self.raw_rootpath))
+            fileinfo_dicts.append(fileinfo_dict)
 
-            fileinfo_record['filepath'] = str(filepath)
-            fileinfo_records.append(fileinfo_record)
+        if len(fileinfo_dicts) == 0:
+            raise RuntimeError(f'no matching files found in {self.raw_rootpath}')
 
         # Create dataframe from all fileinfo records.
-        fileinfo_df = pl.from_dicts(dicts=fileinfo_records, infer_schema_length=1)
+        fileinfo_df = pl.from_dicts(dicts=fileinfo_dicts, infer_schema_length=1)
         fileinfo_df = fileinfo_df.sort(by='filepath')
 
+        fileinfo_df = fileinfo_df.with_columns([
+            pl.col(fileinfo_key).cast(fileinfo_dtype)
+            for fileinfo_key, fileinfo_dtype in self._filename_regex_dtypes.items()
+        ])
+
         return fileinfo_df
 
+    @staticmethod
     def take_subset(
-            self,
-            subset: None | dict[str, float | int | str | list[float | int | str]] = None,
-    ) -> None:
+            fileinfo: pl.DataFrame,
+            subset: None | dict[
+                str, bool | float | int | str | list[bool | float | int | str],
+            ] = None,
+    ) -> pl.DataFrame:
         """Take a subset of the dataset.
 
         Calling this method will alter the fileinfo attribute.
 
         Parameters
         ----------
+        fileinfo : pl.DataFrame
+            File information dataframe.
         subset : dict, optional
-            If specified, load only a subset of the dataset. All keys in the dictionary must be
+            If specified, take a subset of the dataset. All keys in the dictionary must be
             present in the fileinfo dataframe inferred by `infer_fileinfo()`. Values can be either
-            float, int , str or a list of these.
+            bool, float, int , str or a list of these.
+
+        Returns
+        -------
+        pl.DataFrame:
+            Subset of file information dataframe.
 
         Raises
         ------
@@ -186,40 +246,53 @@ class Dataset:
 
         """
         if subset is None:
-            return
+            return fileinfo
 
-        for subset_key, subset_value in subset.items():
-            if subset_key not in self.fileinfo.columns:
-                raise ValueError(
-                    f'subset key {subset_key} must be a column in the fileinfo attribute.'
-                    f' Available columns are: {self.fileinfo.columns}',
-                )
+        if not isinstance(subset, dict):
+            raise TypeError(f'subset must be of type dict but is of type {type(subset)}')
 
+        for subset_key, subset_value in subset.items():
             if not isinstance(subset_key, str):
                 raise TypeError(
                     f'subset keys must be of type str but key {subset_key} is of type'
                     f' {type(subset_key)}',
                 )
 
-            if isinstance(subset_value, (float, int, str)):
+            if subset_key not in fileinfo.columns:
+                raise ValueError(
+                    f'subset key {subset_key} must be a column in the fileinfo attribute.'
+                    f' Available columns are: {fileinfo.columns}',
+                )
+
+            if isinstance(subset_value, (bool, float, int, str)):
                 column_values = [subset_value]
             elif isinstance(subset_value, (list, tuple)):
                 column_values = subset_value
             else:
                 raise TypeError(
-                    f'subset value must be of float, int, str or a list of these but key-value pair'
-                    f'{subset_key}: {subset_value} is of type {type(subset_value)}',
+                    f'subset value must be of type bool, float, int, str or a list of these but'
+                    f' key-value pair {subset_key}: {subset_value} is of type {type(subset_value)}',
                 )
 
-            self.fileinfo = self.fileinfo.filter(pl.col(subset_key).is_in(column_values))
+            fileinfo = fileinfo.filter(pl.col(subset_key).is_in(column_values))
+        return fileinfo
 
-    def load_gaze_files(self, preprocessed: bool = False) -> list[pl.DataFrame]:
+    def load_gaze_files(
+            self,
+            preprocessed: bool = False,
+            preprocessed_dirname: str | None = None,
+    ) -> list[pl.DataFrame]:
         """Load all available gaze data files.
 
         Parameters
         ----------
         preprocessed : bool
             If ``True``, saved preprocessed data will be loaded, otherwise raw data will be loaded.
+        preprocessed_dirname : str
+            One-time usage of an alternative directory name to save data relative to
+            :py:meth:`pymovements.Dataset.path`.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.preprocessed_rootpath`.
 
         Returns
         -------
@@ -235,44 +308,56 @@ class Dataset:
         """
         self._check_fileinfo()
 
-        file_dfs: list[pl.DataFrame] = []
+        gaze_dfs: list[pl.DataFrame] = []
 
         # Read gaze files from fileinfo attribute.
         for file_id, filepath in enumerate(tqdm(self.fileinfo['filepath'])):
-            filepath = Path(filepath)
+            filepath = self.raw_rootpath / filepath
 
             if preprocessed:
-                filepath = self._raw_to_preprocessed_filepath(filepath)
+                filepath = self._raw_to_preprocessed_filepath(
+                    filepath, preprocessed_dirname=preprocessed_dirname,
+                )
 
             if filepath.suffix == '.csv':
-                file_df = pl.read_csv(filepath, **self._custom_read_kwargs)
+                gaze_df = pl.read_csv(filepath, **self._custom_read_kwargs)
             elif filepath.suffix == '.feather':
-                file_df = pl.read_ipc(filepath)
+                gaze_df = pl.read_ipc(filepath)
             else:
-                raise RuntimeError('data files of type {filepath.suffix} are not supported.')
+                raise RuntimeError(f'data files of type {filepath.suffix} are not supported')
 
             # Add fileinfo columns to dataframe.
             for column in self.fileinfo.columns[::-1]:
-                # Preprocessed data already has fileinfo columns.
-                if preprocessed:
-                    continue
-
                 if column == 'filepath':
                     continue
 
                 column_value = self.fileinfo.select(column)[file_id][0, 0]
-                file_df = file_df.select([
+                gaze_df = gaze_df.select([
                     pl.lit(column_value).alias(column),
                     pl.all(),
                 ])
 
-            file_dfs.append(file_df)
+            # Cast columns from fileinfo according to specification.
+            gaze_df = gaze_df.with_columns([
+                pl.col(fileinfo_key).cast(fileinfo_dtype)
+                for fileinfo_key, fileinfo_dtype in self._filename_regex_dtypes.items()
+            ])
 
-        return file_dfs
+            gaze_dfs.append(gaze_df)
+
+        return gaze_dfs
 
-    def load_event_files(self) -> list[pl.DataFrame]:
+    def load_event_files(self, events_dirname: str | None = None) -> list[pl.DataFrame]:
         """Load all available event files.
 
+        Parameters
+        ----------
+        events_dirname : str
+            One-time usage of an alternative directory name to save data relative to
+            :py:meth:`pymovements.Dataset.path`.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.events_rootpath`.
+
         Returns
         -------
         list[pl.DataFrame]
@@ -289,9 +374,9 @@ class Dataset:
 
         # read and preprocess input files
         for _, filepath in enumerate(tqdm(self.fileinfo['filepath'])):
-            filepath = Path(filepath)
+            filepath = self.raw_rootpath / Path(filepath)
 
-            filepath = self._raw_to_event_filepath(filepath)
+            filepath = self._raw_to_event_filepath(filepath, events_dirname=events_dirname)
 
             file_df = pl.read_ipc(filepath)
 
@@ -319,14 +404,8 @@ class Dataset:
             If `gaze` is None or there are no gaze dataframes present in the `gaze` attribute, or
             if experiment is None.
         """
-        if self.gaze is None:
-            raise AttributeError(
-                'gaze files were not loaded yet. please run load() or load_gaze_files() beforehand',
-            )
-        if len(self.gaze) == 0:
-            raise AttributeError('no files present in gaze attribute')
-        if self.experiment is None:
-            raise AttributeError('experiment must be specified for this method.')
+        self._check_gaze_dataframe()
+        self._check_experiment()
 
         disable_progressbar = not verbose
 
@@ -336,7 +415,9 @@ class Dataset:
 
             pixel_positions = file_df.select(pix_position_columns)
 
-            dva_positions = self.experiment.screen.pix2deg(pixel_positions.to_numpy())
+            dva_positions = self.experiment.screen.pix2deg(  # type: ignore[union-attr]
+                pixel_positions.to_numpy(),
+            )
 
             for dva_column_id, dva_column_name in enumerate(dva_position_columns):
                 self.gaze[file_id] = self.gaze[file_id].with_columns(
@@ -367,14 +448,8 @@ class Dataset:
             If `gaze` is None or there are no gaze dataframes present in the `gaze` attribute, or
             if experiment is None.
         """
-        if self.gaze is None:
-            raise AttributeError(
-                'gaze files were not loaded yet. please run load() or load_gaze_files() beforehand',
-            )
-        if len(self.gaze) == 0:
-            raise AttributeError('no files present in gaze attribute')
-        if self.experiment is None:
-            raise AttributeError('experiment must be specified for this method.')
+        self._check_gaze_dataframe()
+        self._check_experiment()
 
         disable_progressbar = not verbose
 
@@ -384,7 +459,9 @@ class Dataset:
 
             positions = file_df.select(position_columns)
 
-            velocities = self.experiment.pos2vel(positions.to_numpy(), method=method, **kwargs)
+            velocities = self.experiment.pos2vel(  # type: ignore[union-attr]
+                positions.to_numpy(), method=method, **kwargs,
+            )
 
             for col_id, velocity_column_name in enumerate(velocity_columns):
                 self.gaze[file_id] = self.gaze[file_id].with_columns(
@@ -417,13 +494,12 @@ class Dataset:
         **kwargs :
             Additional keyword arguments to be passed to the event detection method.
 
+        Raises
+        ------
+        AttributeError
+            If gaze files have not been loaded yet or gaze files do not contain the right columns.
         """
-        if self.gaze is None:
-            raise AttributeError(
-                'gaze files were not loaded yet. please run load() or load_gaze_files() beforehand',
-            )
-        if len(self.gaze) == 0:
-            raise AttributeError('no files present in gaze attribute')
+        self._check_gaze_dataframe()
 
         # Automatically infer eye to use for event detection.
         if eye == 'auto':
@@ -470,7 +546,12 @@ class Dataset:
         for file_id, _ in enumerate(self.events):
             self.events[file_id] = pl.DataFrame(schema=Event.schema)
 
-    def save(self, verbose: int = 1):
+    def save(
+            self,
+            events_dirname: str | None = None,
+            preprocessed_dirname: str | None = None,
+            verbose: int = 1,
+    ):
         """Save preprocessed gaze and event files.
 
         Data will be saved as feather files to ``Dataset.preprocessed_roothpath`` or
@@ -478,18 +559,21 @@ class Dataset:
 
         Parameters
         ----------
+        events_dirname : str
+            One-time usage of an alternative directory name to save data relative to dataset path.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.events_rootpath`.
+        preprocessed_dirname : str
+            One-time usage of an alternative directory name to save data relative to dataset path.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.preprocessed_rootpath`.
         verbose : int
             Verbosity level (0: no print output, 1: show progress bar, 2: print saved filepaths)
         """
-        if verbose > 2:
-            print('save preprocessed gaze files')
-        self.save_preprocessed(verbose=verbose)
-
-        if verbose > 2:
-            print('save event files')
-        self.save_events(verbose=verbose)
+        self.save_events(events_dirname, verbose=verbose)
+        self.save_preprocessed(preprocessed_dirname, verbose=verbose)
 
-    def save_events(self, verbose: int = 1):
+    def save_events(self, events_dirname: str | None = None, verbose: int = 1):
         """Save events to files.
 
         Data will be saved as feather files to ``Dataset.events_roothpath`` with the same directory
@@ -497,23 +581,32 @@ class Dataset:
 
         Parameters
         ----------
+        events_dirname : str
+            One-time usage of an alternative directory name to save data relative to dataset path.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.events_rootpath`.
         verbose : int
             Verbosity level (0: no print output, 1: show progress bar, 2: print saved filepaths)
         """
         disable_progressbar = not verbose
 
         for file_id, event_df in enumerate(tqdm(self.events, disable=disable_progressbar)):
-            raw_filepath = Path(self.fileinfo[file_id, 'filepath'])
-            events_filepath = self._raw_to_event_filepath(raw_filepath)
+            raw_filepath = self.raw_rootpath / Path(self.fileinfo[file_id, 'filepath'])
+            events_filepath = self._raw_to_event_filepath(
+                raw_filepath, events_dirname=events_dirname,
+            )
 
-            events_filepath.parent.mkdir(parents=True, exist_ok=True)
+            for column in event_df.columns:
+                if column in self.fileinfo.columns:
+                    event_df.drop(column)
 
-            if verbose > 2:
+            if verbose >= 2:
                 print('Save file to', events_filepath)
 
+            events_filepath.parent.mkdir(parents=True, exist_ok=True)
             event_df.write_ipc(events_filepath)
 
-    def save_preprocessed(self, verbose: int = 1):
+    def save_preprocessed(self, preprocessed_dirname: str | None = None, verbose: int = 1):
         """Save preprocessed gaze files.
 
         Data will be saved as feather files to ``Dataset.preprocessed_roothpath`` with the same
@@ -521,88 +614,157 @@ class Dataset:
 
         Parameters
         ----------
+        preprocessed_dirname : str
+            One-time usage of an alternative directory name to save data relative to dataset path.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.preprocessed_rootpath`.
         verbose : int
             Verbosity level (0: no print output, 1: show progress bar, 2: print saved filepaths)
         """
         disable_progressbar = not verbose
 
         for file_id, gaze_df in enumerate(tqdm(self.gaze, disable=disable_progressbar)):
-            raw_filepath = Path(self.fileinfo[file_id, 'filepath'])
-            preprocessed_filepath = self._raw_to_preprocessed_filepath(raw_filepath)
+            raw_filepath = self.raw_rootpath / Path(self.fileinfo[file_id, 'filepath'])
+            preprocessed_filepath = self._raw_to_preprocessed_filepath(
+                raw_filepath, preprocessed_dirname=preprocessed_dirname,
+            )
 
-            preprocessed_filepath.parent.mkdir(parents=True, exist_ok=True)
+            for column in gaze_df.columns:
+                if column in self.fileinfo.columns:
+                    gaze_df = gaze_df.drop(column)
 
-            if verbose > 2:
+            if verbose >= 2:
                 print('Save file to', preprocessed_filepath)
 
+            preprocessed_filepath.parent.mkdir(parents=True, exist_ok=True)
             gaze_df.write_ipc(preprocessed_filepath)
 
     @property
-    def rootpath(self) -> Path:
-        """Get the path to the dataset directory.
+    def path(self) -> Path:
+        """The path to the dataset directory.
 
-        The dataset path points to a directory in the specified root directory which is named the
-        same as the respective class.
+        The dataset path points to the dataset directory under the root path. Per default the
+        dataset path points to the exact same directory as the root path. Add ``dataset_dirname``
+        to your initialization call to specify an explicit dataset directory in your root path.
 
         Example
         -------
-        >>> class CustomDataset(Dataset):
-        ...     pass
-        >>> dataset = CustomDataset(root='data')
-        >>> dataset.rootpath  # doctest: +SKIP
-        Path('data/CustomDataset')
+
+        The default behavior is to locate the dataset in the root path without assuming a further
+        dataset directory:
+        >>> dataset = Dataset(root='/path/to/your/dataset', dataset_dirname='.')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/your/dataset')
+
+        To locate the dataset you can also specify an explicit dataset directory name in the root
+        path:
+        >>> dataset = Dataset(root='path/to/all/your/datasets/', dataset_dirname='your_dataset')
+        >>> dataset.path  # doctest: +SKIP
+        Path('path/to/all/your/datasets/your_dataset')
         """
-        return self.root / self.__class__.__name__
+        return self.root / self.dataset_dirname
+
+    @property
+    def root(self) -> Path:
+        """The root path to your dataset.
+
+        Example
+        -------
+        >>> dataset = Dataset(root='/path/to/your/dataset')
+        >>> dataset.root  # doctest: +SKIP
+        Path('/path/to/your/dataset')
+
+        The root stays unaffected by the dataset directory name:
+        >>> dataset = Dataset(root='/path/to/', dataset_dirname='your_dataset')
+        >>> dataset.root  # doctest: +SKIP
+        Path('/path/to')
+        """
+        return self._root
 
     @property
     def events_rootpath(self) -> Path:
-        """Get the path to the directory of the event data.
+        """The path to the directory of the event data.
 
-        The event data directory path points to a directory named `events` in the dataset
-        `rootpath`.
+        The path points to the events directory under the root path.
 
         Example
         -------
-        >>> class CustomDataset(Dataset):
-        ...     pass
-        >>> dataset = CustomDataset(root='data')
+
+        >>> dataset = Dataset(root='/path/to/your/datasets/', dataset_dirname='your_dataset')
+        >>> dataset.events_rootpath  # doctest: +SKIP
+        Path('/path/to/your/datasets/your_dataset/events')
+
+        You can also explicitely specify the event directory name. The default is `events`.
+        >>> dataset = Dataset(
+        ...     root='/path/to/your/datasets/',
+        ...     dataset_dirname='your_dataset',
+        ...     events_dirname='your_events',
+        ... )
         >>> dataset.events_rootpath  # doctest: +SKIP
-        Path('data/CustomDataset/events')
+        Path('/path/to/your/datasets/your_dataset/your_events')
         """
-        return self.rootpath / 'events'
+        return self.path / self.events_dirname
 
     @property
     def preprocessed_rootpath(self) -> Path:
-        """Get the path to the directory of the preprocessed gaze data.
+        """The path to the directory of the preprocessed gaze data.
 
-        The preprocessed data directory path points to a directory named `preprocessed` in the
-        dataset `rootpath`.
+        The path points to the preprocessed data directory under the root path.
 
         Example
         -------
-        >>> class CustomDataset(Dataset):
-        ...     pass
-        >>> dataset = CustomDataset(root='data')
+
+        >>> dataset = Dataset(root='/path/to/your/datasets/', dataset_dirname='your_dataset')
         >>> dataset.preprocessed_rootpath  # doctest: +SKIP
-        Path('data/CustomDataset/preprocessed')
+        Path('/path/to/your/datasets/your_dataset/preprocessed')
+
+        You can also explicitely specify the preprocessed directory name. The default is
+        `preprocessed`.
+        >>> dataset = Dataset(
+        ...     root='/path/to/your/datasets/',
+        ...     dataset_dirname='your_dataset',
+        ...     preprocessed_dirname='your_preprocessed_data',
+        ... )
+        >>> dataset.preprocessed_rootpath  # doctest: +SKIP
+        Path('/path/to/your/datasets/your_dataset/your_preprocessed_data')
         """
-        return self.rootpath / 'preprocessed'
+        return self.path / self.preprocessed_dirname
 
     @property
     def raw_rootpath(self) -> Path:
-        """Get the path to the directory of the raw data.
+        """The path to the directory of the raw data.
 
-        The raw data directory path points to a directory named `raw` in the dataset `rootpath`.
+        The path points to the raw data directory under the root path.
 
         Example
         -------
-        >>> class CustomDataset(Dataset):
-        ...     pass
-        >>> dataset = CustomDataset(root='data')
+
+        >>> dataset = Dataset(root='/path/to/your/datasets/', dataset_dirname='your_dataset')
+        >>> dataset.preprocessed_rootpath  # doctest: +SKIP
+        Path('/path/to/your/datasets/your_dataset/raw')
+
+        You can also explicitely specify the preprocessed directory name. The default is `raw`.
+        >>> dataset = Dataset(
+        ...     root='/path/to/your/datasets/',
+        ...     dataset_dirname='your_dataset',
+        ...     raw_dirname='your_raw_data',
+        ... )
         >>> dataset.raw_rootpath  # doctest: +SKIP
-        Path('data/CustomDataset/raw')
+        Path('/path/to/your/datasets/your_dataset/your_raw_data')
+
+        If your raw data is not in a separate directory under the root path then you can also
+        specify `.` as the directory name. We discourage this and advise the user to keep raw data
+        and preprocessed data separated.
+        ... dataset = Dataset(
+        >>> dataset = Dataset(
+        ...     root='/path/to/your/datasets/',
+        ...     dataset_dirname='your_dataset',
+        ...     raw_dirname='.',
+        ... )
+        >>> dataset.raw_rootpath  # doctest: +SKIP
+        Path('/path/to/your/datasets/your_dataset/your_raw_data')
         """
-        return self.rootpath / 'raw'
+        return self.path / self.raw_dirname
 
     def _check_fileinfo(self) -> None:
         """Check if fileinfo attribute is set and there is at least one row present."""
@@ -613,7 +775,23 @@ class Dataset:
         if len(self.fileinfo) == 0:
             raise AttributeError('no files present in fileinfo attribute')
 
-    def _raw_to_preprocessed_filepath(self, raw_filepath: Path) -> Path:
+    def _check_gaze_dataframe(self) -> None:
+        """Check if gaze attribute is set and there is at least one gaze dataframe available."""
+        if self.gaze is None:
+            raise AttributeError('gaze files were not loaded yet. please run load() beforehand')
+        if len(self.gaze) == 0:
+            raise AttributeError('no files present in gaze attribute')
+
+    def _check_experiment(self) -> None:
+        """Check if experiment attribute has been set."""
+        if self.experiment is None:
+            raise AttributeError('experiment must be specified for this method to work.')
+
+    def _raw_to_preprocessed_filepath(
+            self,
+            raw_filepath: Path,
+            preprocessed_dirname: str | None = None,
+    ) -> Path:
         """Get preprocessed filepath in accordance to filepath of the raw file.
 
         The preprocessed filepath will point to a feather file.
@@ -622,21 +800,33 @@ class Dataset:
         ----------
         raw_filepath : Path
             The Path to the raw file.
+        preprocessed_dirname : str
+            One-time usage of an alternative directory name to save data relative to
+            :py:meth:`pymovements.Dataset.path`.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.preprocessed_rootpath`.
 
         Returns
         -------
         Path
             The Path to the preprocessed feather file.
         """
-        relative_raw_dirpath = raw_filepath.parent.relative_to(self.raw_rootpath)
-        preprocessed_file_dirpath = self.preprocessed_rootpath / relative_raw_dirpath
+        relative_raw_dirpath = raw_filepath.parent
+        relative_raw_dirpath = relative_raw_dirpath.relative_to(self.raw_rootpath)
+
+        if preprocessed_dirname is None:
+            preprocessed_rootpath = self.preprocessed_rootpath
+        else:
+            preprocessed_rootpath = self.path / preprocessed_dirname
+
+        preprocessed_file_dirpath = preprocessed_rootpath / relative_raw_dirpath
 
         # Get new filename for saved feather file.
         preprocessed_filename = raw_filepath.stem + '.feather'
 
         return preprocessed_file_dirpath / preprocessed_filename
 
-    def _raw_to_event_filepath(self, raw_filepath: Path) -> Path:
+    def _raw_to_event_filepath(self, raw_filepath: Path, events_dirname: str | None = None) -> Path:
         """Get event filepath in accordance to filepath of the raw file.
 
         The event filepath will point to a feather file.
@@ -645,14 +835,26 @@ class Dataset:
         ----------
         raw_filepath : Path
             The Path to the raw file.
+        events_dirname : str
+            One-time usage of an alternative directory name to save data relative to
+            :py:meth:`pymovements.Dataset.path`.
+            This argument is used only for this single call and does not alter
+            :py:meth:`pymovements.Dataset.events_rootpath`.
 
         Returns
         -------
         Path
             The Path to the event feather file.
         """
-        relative_raw_dirpath = raw_filepath.parent.relative_to(self.raw_rootpath)
-        events_file_dirpath = self.events_rootpath / relative_raw_dirpath
+        relative_raw_dirpath = raw_filepath.parent
+        relative_raw_dirpath = relative_raw_dirpath.relative_to(self.raw_rootpath)
+
+        if events_dirname is None:
+            events_rootpath = self.events_rootpath
+        else:
+            events_rootpath = self.path / events_dirname
+
+        events_file_dirpath = events_rootpath / relative_raw_dirpath
 
         # Get new filename for saved feather file.
         events_filename = raw_filepath.stem + '.feather'
diff --git a/src/pymovements/datasets/gazebase.py b/src/pymovements/datasets/gazebase.py
index 0c9c865..eb488a0 100644
--- a/src/pymovements/datasets/gazebase.py
+++ b/src/pymovements/datasets/gazebase.py
@@ -18,6 +18,10 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """This module provides an interface to the GazeBase dataset."""
+from __future__ import annotations
+
+from pathlib import Path
+
 from pymovements.base import Experiment
 from pymovements.datasets.public_dataset import PublicDataset
 
@@ -49,6 +53,9 @@ class GazeBase(PublicDataset):
     ... )
     >>> dataset.load()  # doctest: +SKIP
     """
+    # pylint: disable=similarities
+    # The PublicDataset child classes potentially share code chunks for definitions.
+
     _mirrors = [
         'https://figshare.com/ndownloader/files/',
     ]
@@ -97,11 +104,102 @@ class GazeBase(PublicDataset):
         'new_columns': list(_column_map.values()),
     }
 
-    def __init__(self, **kwargs):
+    def __init__(
+            self,
+            root: str | Path,
+            download: bool = False,
+            extract: bool = False,
+            remove_finished: bool = False,
+            dataset_dirname: str = 'GazeBase',
+            downloads_dirname: str = 'downloads',
+            raw_dirname: str = 'raw',
+            preprocessed_dirname: str = 'preprocessed',
+            events_dirname: str = 'events',
+    ):
+        """Initialize the GazeBase dataset object.
+
+        If desired, dataset resources are downloaded with ``download=True`` and extracted with
+        ``extract=True``. To save space on your device you can remove the archive files after
+        successful extraction with ``remove_finished=True``.
+
+        Downloaded archives are automatically checked for integrity by comparing MD5 checksums.
+
+        You can set up a custom directory structure by populating the particular dirname attributes.
+        See :py:attr:`~pymovements.dataset.PublicDataset.dataset_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.raw_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.preprocessed_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.events_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.downloads_dirname` for details.
+
+        Parameters
+        ----------
+        root : str, Path
+            Path to the root directory of the dataset.
+        download : bool
+            Download all dataset resources.
+        extract : bool
+            Extract dataset archive files.
+        remove_finished : bool
+            Remove archive files after extraction.
+        dataset_dirname : str, optional
+            Dataset directory name under root path. Can be `.` if dataset is located in root path.
+            Default: `.`
+        downloads_dirname : str, optional
+            Name of directory to store downloaded data.Default: `downloads`
+        raw_dirname ; str, optional
+            Name of directory under dataset path that contains raw data. Can be `.` if raw data is
+            located in dataset path. We advise the user to keep the original raw data separate from
+            the preprocessed / event data. Default: `raw`
+        preprocessed_dirname : str, optional
+            Name of directory under dataset path that will be used to store preprocessed data. We
+            advise the user to keep the preprocessed data separate from the original raw data.
+            Default: `preprocessed`
+        events_dirname : str, optional
+            Name of directory under dataset path that will be used to store event data. We advise
+            the user to keep the event data separate from the original raw data. Default: `events`
+        """
         super().__init__(
+            root=root,
+            download=download,
+            extract=extract,
+            remove_finished=remove_finished,
             experiment=self._experiment,
             filename_regex=self._filename_regex,
             filename_regex_dtypes=self._filename_regex_dtypes,
             custom_read_kwargs=self._read_csv_kwargs,
-            **kwargs,
+            dataset_dirname=dataset_dirname,
+            downloads_dirname=downloads_dirname,
+            raw_dirname=raw_dirname,
+            preprocessed_dirname=preprocessed_dirname,
+            events_dirname=events_dirname,
         )
+
+    @property
+    def path(self) -> Path:
+        """Get the path to the dataset directory.
+
+        The dataset path points to the dataset directory under the root path. Per default the
+        dataset directory name is equal to the class name.
+
+        Example
+        -------
+        The default behaviour is to locate the data set in a directory under the root path with the
+        same name as the class name:
+        >>> dataset = GazeBase(root='/path/to/all/your/datasets')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/GazeBase')
+
+        You can specify an explicit dataset directory name:
+        >>> dataset = GazeBase(
+        ...     root='/path/to/all/your/datasets',
+        ...     dataset_dirname='gazebase',
+        ... )
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/gazebase')
+
+        You can specify to use the root path to be the actual dataset directory:
+        >>> dataset = GazeBase(root='/path/to/gazebase/dataset', dataset_dirname='.')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/gazebase/dataset')
+        """
+        return super().path
diff --git a/src/pymovements/datasets/judo1000.py b/src/pymovements/datasets/judo1000.py
index 673b97c..1b996e6 100644
--- a/src/pymovements/datasets/judo1000.py
+++ b/src/pymovements/datasets/judo1000.py
@@ -18,6 +18,10 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """This module provides an interface to the JuDo1000 dataset."""
+from __future__ import annotations
+
+from pathlib import Path
+
 from pymovements.base import Experiment
 from pymovements.datasets.public_dataset import PublicDataset
 
@@ -45,6 +49,9 @@ class JuDo1000(PublicDataset):
     ... )
     >>> dataset.load()  # doctest: +SKIP
     """
+    # pylint: disable=similarities
+    # The PublicDataset child classes potentially share code chunks for definitions.
+
     _mirrors = [
         'https://osf.io/download/',
     ]
@@ -90,11 +97,102 @@ class JuDo1000(PublicDataset):
         'new_columns': list(_column_map.values()),
     }
 
-    def __init__(self, **kwargs):
+    def __init__(
+            self,
+            root: str | Path,
+            download: bool = False,
+            extract: bool = False,
+            remove_finished: bool = False,
+            dataset_dirname: str = 'JuDo1000',
+            downloads_dirname: str = 'downloads',
+            raw_dirname: str = 'raw',
+            preprocessed_dirname: str = 'preprocessed',
+            events_dirname: str = 'events',
+    ):
+        """Initialize the JuDo1000 dataset object.
+
+        If desired, dataset resources are downloaded with ``download=True`` and extracted with
+        ``extract=True``. To save space on your device you can remove the archive files after
+        successful extraction with ``remove_finished=True``.
+
+        Downloaded archives are automatically checked for integrity by comparing MD5 checksums.
+
+        You can set up a custom directory structure by populating the particular dirname attributes.
+        See :py:attr:`~pymovements.dataset.PublicDataset.dataset_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.raw_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.preprocessed_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.events_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.downloads_dirname` for details.
+
+        Parameters
+        ----------
+        root : str, Path
+            Path to the root directory of the dataset.
+        download : bool
+            Download all dataset resources.
+        extract : bool
+            Extract dataset archive files.
+        remove_finished : bool
+            Remove archive files after extraction.
+        dataset_dirname : str, optional
+            Dataset directory name under root path. Can be `.` if dataset is located in root path.
+            Default: `.`
+        downloads_dirname : str, optional
+            Name of directory to store downloaded data.Default: `downloads`
+        raw_dirname ; str, optional
+            Name of directory under dataset path that contains raw data. Can be `.` if raw data is
+            located in dataset path. We advise the user to keep the original raw data separate from
+            the preprocessed / event data. Default: `raw`
+        preprocessed_dirname : str, optional
+            Name of directory under dataset path that will be used to store preprocessed data. We
+            advise the user to keep the preprocessed data separate from the original raw data.
+            Default: `preprocessed`
+        events_dirname : str, optional
+            Name of directory under dataset path that will be used to store event data. We advise
+            the user to keep the event data separate from the original raw data. Default: `events`
+        """
         super().__init__(
+            root=root,
+            download=download,
+            extract=extract,
+            remove_finished=remove_finished,
             experiment=self._experiment,
             filename_regex=self._filename_regex,
             filename_regex_dtypes=self._filename_regex_dtypes,
             custom_read_kwargs=self._read_csv_kwargs,
-            **kwargs,
+            dataset_dirname=dataset_dirname,
+            downloads_dirname=downloads_dirname,
+            raw_dirname=raw_dirname,
+            preprocessed_dirname=preprocessed_dirname,
+            events_dirname=events_dirname,
         )
+
+    @property
+    def path(self) -> Path:
+        """The path to the dataset directory.
+
+        The dataset path points to the dataset directory under the root path. Per default the
+        dataset directory name is equal to the class name.
+
+        Example
+        -------
+        The default behaviour is to locate the data set in a directory under the root path with the
+        same name as the class name:
+        >>> dataset = JuDo1000(root='/path/to/all/your/datasets')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/JuDo1000')
+
+        You can specify an explicit dataset directory name:
+        >>> dataset = JuDo1000(
+        ...     root='/path/to/all/your/datasets',
+        ...     dataset_dirname='judo',
+        ... )
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/judo')
+
+        You can specify to use the root path to be the actual dataset directory:
+        >>> dataset = JuDo1000(root='/path/to/judo/dataset', dataset_dirname='.')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/judo/dataset')
+        """
+        return super().path
diff --git a/src/pymovements/datasets/public_dataset.py b/src/pymovements/datasets/public_dataset.py
index dac3f44..4602553 100644
--- a/src/pymovements/datasets/public_dataset.py
+++ b/src/pymovements/datasets/public_dataset.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2023 The pymovements Project Authors
+# Copyright (c) 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
@@ -22,8 +22,11 @@ from __future__ import annotations
 
 from abc import ABCMeta
 from abc import abstractmethod
+from pathlib import Path
+from typing import Any
 from urllib.error import URLError
 
+from pymovements.base import Experiment
 from pymovements.datasets.dataset import Dataset
 from pymovements.utils.archives import extract_archive
 from pymovements.utils.downloads import download_file
@@ -38,27 +41,90 @@ class PublicDataset(Dataset, metaclass=ABCMeta):
     """
 
     def __init__(
-        self,
-        root: str,
-        download: bool = False,
-        extract: bool = False,
-        remove_finished: bool = False,
-        **kwargs,
+            self,
+            root: str | Path,
+            download: bool = False,
+            extract: bool = False,
+            remove_finished: bool = False,
+            experiment: Experiment | None = None,
+            filename_regex: str = '.*',
+            filename_regex_dtypes: dict[str, type] | None = None,
+            custom_read_kwargs: dict[str, Any] | None = None,
+            dataset_dirname: str | None = None,
+            downloads_dirname: str = 'downloads',
+            raw_dirname: str = 'raw',
+            preprocessed_dirname: str = 'preprocessed',
+            events_dirname: str = 'events',
     ):
-        """Initialize dataset.
+        """Initialize the public dataset object.
 
-        If desired, dataset resources will be downloaded and extracted.
+        If desired, dataset resources are downloaded with ``download=True`` and extracted with
+        ``extract=True``. To save space on your device you can remove the archive files after
+        successful extraction with ``remove_finished=True``.
+
+        Downloaded archives are automatically checked for integrity by comparing MD5 checksums.
+
+        You can set up a custom directory structure by populating the particular dirname attributes.
+        See :py:attr:`~pymovements.dataset.PublicDataset.dataset_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.raw_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.preprocessed_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.events_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.downloads_dirname` for details.
 
         Parameters
         ----------
+        root : str, Path
+            Path to the root directory of the dataset.
         download : bool
             Download all dataset resources.
         extract : bool
             Extract dataset archive files.
         remove_finished : bool
             Remove archive files after extraction.
+        experiment : Experiment
+            The experiment definition.
+        filename_regex : str
+            Regular expression which needs to be matched before trying to load the file. Named
+            groups will appear in the `fileinfo` dataframe.
+        filename_regex_dtypes : dict[str, type], optional
+            If named groups are present in the `filename_regex`, this makes it possible to cast
+            specific named groups to a particular datatype.
+        custom_read_kwargs : dict[str, Any], optional
+            If specified, these keyword arguments will be passed to the file reading function.
+        dataset_dirname : str, optional
+            Dataset directory name under root path. Can be `.` if dataset is located in root path.
+            Default: `.`
+        downloads_dirname : str, optional
+            Name of directory to store downloaded data.Default: `downloads`
+        raw_dirname ; str, optional
+            Name of directory under dataset path that contains raw data. Can be `.` if raw data is
+            located in dataset path. We advise the user to keep the original raw data separate from
+            the preprocessed / event data. Default: `raw`
+        preprocessed_dirname : str, optional
+            Name of directory under dataset path that will be used to store preprocessed data. We
+            advise the user to keep the preprocessed data separate from the original raw data.
+            Default: `preprocessed`
+        events_dirname : str, optional
+            Name of directory under dataset path that will be used to store event data. We advise
+            the user to keep the event data separate from the original raw data. Default: `events`
         """
-        super().__init__(root=root, **kwargs)
+        if dataset_dirname is None:
+            dataset_dirname = self.__class__.__name__
+
+        super().__init__(
+            root=root,
+            experiment=experiment,
+            filename_regex=filename_regex,
+            filename_regex_dtypes=filename_regex_dtypes,
+            custom_read_kwargs=custom_read_kwargs,
+            dataset_dirname=dataset_dirname,
+            raw_dirname=raw_dirname,
+            preprocessed_dirname=preprocessed_dirname,
+            events_dirname=events_dirname,
+        )
+
+        self.downloads_dirname = downloads_dirname
+
         if download:
             self.download()
 
@@ -85,7 +151,7 @@ class PublicDataset(Dataset, metaclass=ABCMeta):
                 try:
                     download_file(
                         url=url,
-                        dirpath=self.rootpath,
+                        dirpath=self.downloads_rootpath,
                         filename=resource['filename'],
                         md5=resource['md5'],
                     )
@@ -116,7 +182,7 @@ class PublicDataset(Dataset, metaclass=ABCMeta):
 
         for resource in self._resources:
             extract_archive(
-                source_path=self.rootpath / resource['filename'],
+                source_path=self.downloads_rootpath / resource['filename'],
                 destination_path=self.raw_rootpath,
                 recursive=True,
                 remove_finished=remove_finished,
@@ -143,3 +209,90 @@ class PublicDataset(Dataset, metaclass=ABCMeta):
 
         All values should be of type string.
         """
+
+    @property
+    def path(self) -> Path:
+        """The path to the dataset directory.
+
+        The dataset path points to the dataset directory under the root path. Per default the
+        dataset directory name is equal to the class name.
+
+        Example
+        -------
+        Let's define a custom class first with one mirror and one resource.
+        >>> class CustomPublicDataset(PublicDataset):
+        ...     _mirrors = ['https://www.example.com/']
+        ...     _resources = [
+        ...         {
+        ...             'resource': 'relative_path_to_resource',
+        ...             'filename': 'example_dataset.zip',
+        ...             'md5': 'md5md5md5md5md5md5md5md5md5md5md',
+        ...         },
+        ...     ]
+
+        The default behaviour is to locate the data set in a directory under the root path with the
+        same name as the class name:
+        >>> dataset = CustomPublicDataset(root='/path/to/all/your/datasets')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/CustomPublicDataset')
+
+        You can specify an explicit dataset directory name:
+        >>> dataset = CustomPublicDataset(
+        ...     root='/path/to/all/your/datasets',
+        ...     dataset_dirname='custom_dataset',
+        ... )
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/custom_dataset')
+
+        You can specify to use the root path to be the actual dataset directory:
+        >>> dataset = CustomPublicDataset(root='/path/to/your/dataset', dataset_dirname='.')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/your/dataset')
+        """
+        return super().path
+
+    @property
+    def downloads_rootpath(self) -> Path:
+        """The path to the directory of the raw data.
+
+        The download path points to the download directory under the root path.
+
+        Example
+        -------
+        Let's define a custom class first with one mirror and one resource.
+        >>> class CustomPublicDataset(PublicDataset):
+        ...     _mirrors = ['https://www.example.com/']
+        ...     _resources = [
+        ...         {
+        ...             'resource': 'relative_path_to_resource',
+        ...             'filename': 'example_dataset.zip',
+        ...             'md5': 'md5md5md5md5md5md5md5md5md5md5md',
+        ...         },
+        ...     ]
+
+        >>> dataset = CustomPublicDataset(root='/path/to/your/datasets/')
+        >>> dataset.downloads_rootpath  # doctest: +SKIP
+        Path('/path/to/your/datasets/CustomPublicDataset/downloads')
+
+        You can also explicitely specify the preprocessed directory name. The default is `raw`.
+        >>> dataset = CustomPublicDataset(
+        ...     root='/path/to/your/datasets/',
+        ...     dataset_dirname='your_dataset',
+        ...     downloads_dirname='your_raw_data',
+        ... )
+        >>> dataset.downloads_rootpath  # doctest: +SKIP
+        Path('/path/to/your/datasets/your_dataset/your_raw_data')
+
+        If your raw data is not in a separate directory under the root path then you can also
+        specify `.` as the directory name. We discourage this and advise the user to keep raw data
+        and preprocessed data separated.
+        ... dataset = Dataset(
+        >>> dataset = CustomPublicDataset(
+        ...     root='/path/to/your/datasets/',
+        ...     dataset_dirname='your_dataset',
+        ...     raw_dirname='.',
+        ... )
+        >>> dataset.preprocessed_rootpath  # doctest: +SKIP
+        Path('/path/to/your/datasets/your_dataset/your_raw_data')
+        """
+        return self.path / self.downloads_dirname
diff --git a/src/pymovements/events/engbert.py b/src/pymovements/events/engbert.py
index c28eba7..be0bed5 100644
--- a/src/pymovements/events/engbert.py
+++ b/src/pymovements/events/engbert.py
@@ -30,6 +30,7 @@ import polars as pl
 from pymovements.events.events import Saccade
 from pymovements.transforms import consecutive
 from pymovements.utils.checks import check_shapes_positions_velocities
+from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 def microsaccades(
@@ -39,6 +40,7 @@ def microsaccades(
     threshold_factor: float = 6,
     minimum_duration: int = 6,
     minimum_threshold: float = 1e-10,
+    include_nan: bool = False,
 ) -> pl.DataFrame:
     """Detect micro-saccades from velocity gaze sequence.
 
@@ -66,6 +68,8 @@ def microsaccades(
     minimum_threshold : float
         minimal threshold value. Raises ValueError if calculated threshold is too low.
         Default: 1e-10
+    include_nan: bool
+        Indicator, whether we want to split events on missing/corrupt value (np.nan)
 
     Returns
     -------
@@ -100,13 +104,24 @@ def microsaccades(
     radius = threshold * threshold_factor
 
     # If value is greater than 1, point lies outside the ellipse.
-    outside_ellipse = np.greater(np.sum(np.power(velocities / radius, 2), axis=1), 1)
+    candidate_mask = np.greater(np.sum(np.power(velocities / radius, 2), axis=1), 1)
 
-    # Get all indices with velocities outside of ellipse.
-    outside_ellipse_indices = np.where(outside_ellipse)[0]
+    # Add nans to candidates if desired.
+    if include_nan:
+        candidate_mask = np.logical_or(candidate_mask, np.isnan(velocities).any(axis=1))
+
+    # Get indices of true values in candidate mask.
+    candidate_indices = np.where(candidate_mask)[0]
 
     # Get all saccade candidates by grouping all consecutive indices.
-    candidates = consecutive(arr=outside_ellipse_indices)
+    candidates = consecutive(arr=candidate_indices)
+
+    # Filter np.nan in candidates (delete starting/ending np.nans)
+    if include_nan:
+        candidates = filter_candidates_remove_nans(
+            candidates,
+            velocities,
+        )
 
     # Filter all candidates by minimum duration.
     candidates = [candidate for candidate in candidates if len(candidate) >= minimum_duration]
diff --git a/src/pymovements/events/idt.py b/src/pymovements/events/idt.py
index 24b3118..f7762d3 100644
--- a/src/pymovements/events/idt.py
+++ b/src/pymovements/events/idt.py
@@ -27,6 +27,8 @@ import polars as pl
 
 from pymovements.events.events import Fixation
 from pymovements.utils.checks import check_shapes_positions_velocities
+from pymovements.utils.filters import events_split_nans
+from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 def dispersion(positions: list[list[float]] | np.ndarray) -> float:
@@ -46,7 +48,7 @@ def dispersion(positions: list[list[float]] | np.ndarray) -> float:
     dispersion: float
         Dispersion of the group of points.
     """
-    return sum(np.max(positions, axis=0) - np.min(positions, axis=0))
+    return sum(np.nanmax(positions, axis=0) - np.nanmin(positions, axis=0))
 
 
 def idt(
@@ -54,6 +56,7 @@ def idt(
         velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
         dispersion_threshold: float,
         minimum_duration: int,
+        include_nan: bool = False,
 ) -> pl.DataFrame:
     """
     Fixation identification based on dispersion threshold.
@@ -77,6 +80,8 @@ def idt(
         Threshold for dispersion for a group of consecutive samples to be identified as fixation
     minimum_duration: int
         Minimum fixation duration in number of samples
+    include_nan: bool
+        Indicator, whether we want to split events on missing/corrupt value (np.nan)
 
     Returns
     -------
@@ -120,15 +125,44 @@ def idt(
                 if win_end == len(positions):
                     break
 
-            # Note a fixation at the centroid of the window points.
-            centroid = np.mean(positions[win_start:win_end - 1], axis=0)
-
-            fixations.append({
-                'type': 'fixation',
-                'onset': win_start,
-                'offset': win_end - 1,
-                'position': centroid.tolist(),
-            })
+            # check for np.nan values
+            if np.sum(np.isnan(positions[win_start:win_end - 1])) > 0:
+                tmp_candidates = [np.arange(win_start, win_end - 1, 1)]
+                tmp_candidates = filter_candidates_remove_nans(
+                    candidates=tmp_candidates,
+                    values=positions,
+                )
+                # split events if include_nan == False
+                if not include_nan:
+                    tmp_candidates = events_split_nans(
+                        candidates=tmp_candidates,
+                        values=positions,
+                    )
+
+                # Filter all candidates by minimum duration.
+                tmp_candidates = [
+                    candidate for candidate in tmp_candidates if len(candidate) >= minimum_duration
+                ]
+
+                for candidate in tmp_candidates:
+                    fixations.append({
+                        'type': 'fixation',
+                        'onset': candidate[0],
+                        'offset': candidate[-1],
+                        'position': np.nanmean(positions[candidate], axis=0).tolist(),
+                    })
+
+            else:
+                # Note a fixation at the centroid of the window points.
+                # the mean is computed using all values except np.nan
+                centroid = np.nanmean(positions[win_start:win_end - 1], axis=0)
+
+                fixations.append({
+                    'type': 'fixation',
+                    'onset': win_start,
+                    'offset': win_end - 1,
+                    'position': centroid.tolist(),
+                })
 
             # Remove window points from points.
             # Initialize new window excluding the previous window
diff --git a/src/pymovements/events/ivt.py b/src/pymovements/events/ivt.py
index 58df6b4..bc59cdc 100644
--- a/src/pymovements/events/ivt.py
+++ b/src/pymovements/events/ivt.py
@@ -29,6 +29,7 @@ from pymovements.events.events import Fixation
 from pymovements.transforms import consecutive
 from pymovements.transforms import norm
 from pymovements.utils.checks import check_shapes_positions_velocities
+from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 def ivt(
@@ -36,6 +37,7 @@ def ivt(
         velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
         velocity_threshold: float,
         minimum_duration: int,
+        include_nan: bool = False,
 ) -> pl.DataFrame:
     """
     Identification of fixations based on velocity-threshold
@@ -58,6 +60,8 @@ def ivt(
         velocity is below the threshold, the point is classified as a fixation.
     minimum_duration: int
         Minimum fixation duration in number of samples
+    include_nan: bool
+        Indicator, whether we want to split events on missing/corrupt value (np.nan)
 
     Returns
     -------
@@ -84,23 +88,38 @@ def ivt(
 
     # Get all indices with norm-velocities below threshold.
     velocity_norm = norm(velocities, axis=1)
-    below_threshold_indices = np.where(velocity_norm < velocity_threshold)[0]
+    candidate_mask = velocity_norm < velocity_threshold
+
+    # Add nans to candidates if desired.
+    if include_nan:
+        candidate_mask = np.logical_or(candidate_mask, np.isnan(velocities).any(axis=1))
+
+    # Get indices of true values in candidate mask.
+    candidate_ind = np.where(candidate_mask)[0]
 
     # Get all fixation candidates by grouping all consecutive indices.
-    candidates = consecutive(arr=below_threshold_indices)
+    candidates = consecutive(arr=candidate_ind)
+
+    # Filter np.nan in candidates (delete starting/ending np.nans)
+    if include_nan:
+        candidates = filter_candidates_remove_nans(
+            candidates,
+            velocities,
+        )
 
     # Filter all candidates by minimum duration.
     candidates = [candidate for candidate in candidates if len(candidate) >= minimum_duration]
 
-    # Create ficaitons from valid candidates. First channel is onset, second channel is offset.
+    # Create fixations from valid candidates. First channel is onset, second channel is offset.
     fixations = np.array([
         (candidate_indices[0], candidate_indices[-1])
         for candidate_indices in candidates
     ])
 
     # Calculate centroid positions for fixations.
+    # the mean is computed using all values except np.nan
     centroids = [
-        np.mean(positions[fixation[0]:fixation[1]], axis=0, dtype=np.float64).tolist()
+        np.nanmean(positions[fixation[0]:fixation[1]], axis=0, dtype=np.float64).tolist()
         for fixation in fixations
     ]
 
diff --git a/src/pymovements/synthetic/step_function.py b/src/pymovements/synthetic/step_function.py
index 435e5aa..bd14587 100644
--- a/src/pymovements/synthetic/step_function.py
+++ b/src/pymovements/synthetic/step_function.py
@@ -70,7 +70,7 @@ def step_function(
     ...     values=[1., 2., 3.],
     ...     start_value=0,
     ... )
-    array([0., 0., 1., 1., 1., 2., 2., 2., 2., 3.])
+    array([0., 0., 1., 1., 1., 2., 2., 2., 2., 3.], dtype=float32)
 
     >>> # multi-channel example
     >>> step_function(
@@ -88,7 +88,7 @@ def step_function(
            [ 3.,  4.],
            [ 3.,  4.],
            [ 3.,  4.],
-           [ 3.,  4.]])
+           [ 3.,  4.]], dtype=float32)
     """
     # Check that steps and values have equal length.
     if len(steps) != len(values):
@@ -136,6 +136,9 @@ def step_function(
     else:
         arr = np.tile(start_value, (length, 1))
 
+    # change type of elements to float32, to allow np.nan
+    arr = np.array(arr, dtype=np.float32)
+
     # Iterate through all steps except the last.
     for begin, end, value in zip(steps[:-1], steps[1:], values[:-1]):
         arr[begin:end] = value
diff --git a/src/pymovements/utils/filters.py b/src/pymovements/utils/filters.py
new file mode 100644
index 0000000..7039824
--- /dev/null
+++ b/src/pymovements/utils/filters.py
@@ -0,0 +1,101 @@
+# Copyright (c) 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 holds filter specific funtions.
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from pymovements.transforms import consecutive
+
+
+def filter_candidates_remove_nans(
+        candidates: list[np.ndarray],
+        values: np.ndarray,
+) -> list[np.ndarray]:
+    """
+    Filters a list of candidates for an event-detection algorithm
+
+    Removes leading and ending np.nans for all candidates in candidates
+
+    Parameters
+    ----------
+    candidates: list, list of candidates
+        List of candidates; each candidate consists of a list of indices
+    values: array-like, shape (N, 1) or shape (N, 2)
+        Corresponding continuous 1D/2D values time series.
+
+    Returns
+    -------
+    list
+        a filtered list of candidates
+    """
+    values = np.array(values)
+    return_candidates = []
+    for candidate in candidates:
+        if len(candidate) == 0:
+            continue
+        cand_values = values[np.array(candidate)]
+        start_id = 0
+        while np.sum(np.isnan(cand_values[start_id, :])) > 0:
+            start_id += 1
+        end_id = len(cand_values) - 1
+        while np.sum(np.isnan(cand_values[end_id, :])) > 0:
+            end_id -= 1
+        cur_candidate = list(candidate[start_id:end_id + 1])
+        return_candidates.append(np.array(cur_candidate))
+    return return_candidates
+
+
+def events_split_nans(
+        candidates: list[np.ndarray],
+        values: np.ndarray,
+) -> list[np.ndarray]:
+    """
+    Filters a list of candidates for an event-detection algorithm
+
+    Splits events if np.nans are within an event
+
+    Parameters
+    ----------
+    candidates: list, list of candidates
+        List of candidates; each candidate consists of a list of indices
+    values: array-like, shape (N, 1) or shape (N, 2)
+        Corresponding continuous 1D/2D values time series.
+
+    Returns
+    -------
+    list
+        a filtered list of candidates
+    """
+    values = np.array(values)
+    return_candidates = []
+    for candidate in candidates:
+        if len(candidate) == 0:
+            continue
+        cur_values = values[np.array(candidate)]
+        nan_candidates = consecutive(arr=np.where(~np.isnan(np.sum(cur_values, axis=1)))[0])
+        cand_list = [
+            np.array(candidate[candidate_indices[0]:candidate_indices[-1] + 1])
+            for candidate_indices in nan_candidates
+        ]
+        return_candidates += cand_list
+    return return_candidates


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 8e707d1..c89fd97 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -97,10 +97,14 @@ jobs:
       - uses: actions/checkout@v3
         with:
           fetch-depth: 0
+      - name: Install pandoc
+        uses: r-lib/actions/setup-pandoc@v2
+        with:
+          pandoc-version: '2.19.2'
       - name: Install base python for tox
         uses: actions/setup-python@v4
         with:
-          python-version: "3.9"
+          python-version: "3.7"
       - name: Install tox
         run: python -m pip install tox
       - name: Setup test environment
diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py
index 87aa9f5..e964177 100644
--- a/tests/datasets/test_dataset.py
+++ b/tests/datasets/test_dataset.py
@@ -18,12 +18,455 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """Test all functionality in pymovements.datasets.dataset."""
+import shutil
+from pathlib import Path
+
+import numpy as np
 import polars as pl
 import pytest
 from polars.testing import assert_frame_equal
 
+from pymovements.base import Experiment
 from pymovements.datasets.dataset import Dataset
+from pymovements.events.engbert import microsaccades
 from pymovements.events.events import Event
+from pymovements.events.events import Fixation
+from pymovements.events.events import Saccade
+from pymovements.events.ivt import ivt
+
+
+def create_raw_gaze_files_from_fileinfo(gaze_dfs, fileinfo, rootpath):
+    rootpath.mkdir(parents=True, exist_ok=True)
+
+    for gaze_df, fileinfo_row in zip(gaze_dfs, fileinfo.to_dicts()):
+        filepath = fileinfo_row['filepath']
+
+        for key in fileinfo_row.keys():
+            if key in gaze_df.columns:
+                gaze_df = gaze_df.drop(key)
+
+        gaze_df.write_csv(rootpath / filepath)
+
+
+def create_preprocessed_gaze_files_from_fileinfo(gaze_dfs, fileinfo, rootpath):
+    rootpath.mkdir(parents=True, exist_ok=True)
+
+    for gaze_df, fileinfo_row in zip(gaze_dfs, fileinfo.to_dicts()):
+        filepath = fileinfo_row['filepath']
+        filepath = filepath.replace('csv', 'feather')
+
+        for key in fileinfo_row.keys():
+            if key in gaze_df.columns:
+                gaze_df = gaze_df.drop(key)
+
+        gaze_df.write_ipc(rootpath / filepath)
+
+
+def create_event_files_from_fileinfo(gaze_dfs, fileinfo, rootpath):
+    rootpath.mkdir(parents=True, exist_ok=True)
+
+    for gaze_df, fileinfo_row in zip(gaze_dfs, fileinfo.to_dicts()):
+        filepath = fileinfo_row['filepath']
+        filepath = filepath.replace('csv', 'feather')
+
+        for key in fileinfo_row.keys():
+            if key in gaze_df.columns:
+                gaze_df = gaze_df.drop(key)
+
+        gaze_df.write_ipc(rootpath / filepath)
+
+
+def mock_toy(rootpath, raw_fileformat='csv'):
+    subject_ids = list(range(1, 21))
+
+    fileinfo = pl.DataFrame(data={'subject_id': subject_ids}, schema={'subject_id': pl.Int64})
+
+    fileinfo = fileinfo.with_columns([
+        pl.format('{}.' + raw_fileformat, 'subject_id').alias('filepath'),
+    ])
+
+    fileinfo = fileinfo.sort(by='filepath')
+
+    gaze_dfs = []
+    for fileinfo_row in fileinfo.to_dicts():
+        gaze_df = pl.from_dict(
+            {
+                'subject_id': fileinfo_row['subject_id'],
+                'time': np.arange(1000),
+                'x_left_pix': np.zeros(1000),
+                'y_left_pix': np.zeros(1000),
+                'x_right_pix': np.zeros(1000),
+                'y_right_pix': np.zeros(1000),
+            },
+            schema={
+                'subject_id': pl.Int64,
+                'time': pl.Int64,
+                'x_left_pix': pl.Float64,
+                'y_left_pix': pl.Float64,
+                'x_right_pix': pl.Float64,
+                'y_right_pix': pl.Float64,
+            },
+        )
+        gaze_dfs.append(gaze_df)
+
+    create_raw_gaze_files_from_fileinfo(gaze_dfs, fileinfo, rootpath / 'raw')
+
+    preprocessed_gaze_dfs = []
+    for fileinfo_row in fileinfo.to_dicts():
+        gaze_df = pl.from_dict(
+            {
+                'subject_id': fileinfo_row['subject_id'],
+                'time': np.arange(1000),
+                'x_left_pix': np.zeros(1000),
+                'y_left_pix': np.zeros(1000),
+                'x_right_pix': np.zeros(1000),
+                'y_right_pix': np.zeros(1000),
+                'x_left_dva': np.zeros(1000),
+                'y_left_dva': np.zeros(1000),
+                'x_right_dva': np.zeros(1000),
+                'y_right_dva': np.zeros(1000),
+                'x_left_vel': np.zeros(1000),
+                'y_left_vel': np.zeros(1000),
+                'x_right_vel': np.zeros(1000),
+                'y_right_vel': np.zeros(1000),
+            },
+            schema={
+                'subject_id': pl.Int64,
+                'time': pl.Int64,
+                'x_left_pix': pl.Float64,
+                'y_left_pix': pl.Float64,
+                'x_right_pix': pl.Float64,
+                'y_right_pix': pl.Float64,
+                'x_left_dva': pl.Float64,
+                'y_left_dva': pl.Float64,
+                'x_right_dva': pl.Float64,
+                'y_right_dva': pl.Float64,
+                'x_left_vel': pl.Float64,
+                'y_left_vel': pl.Float64,
+                'x_right_vel': pl.Float64,
+                'y_right_vel': pl.Float64,
+            },
+        )
+        preprocessed_gaze_dfs.append(gaze_df)
+
+    create_preprocessed_gaze_files_from_fileinfo(
+        preprocessed_gaze_dfs, fileinfo, rootpath / 'preprocessed',
+    )
+
+    event_dfs = []
+    for fileinfo_row in fileinfo.to_dicts():
+        event_df = pl.from_dict(
+            {
+                'type': 'saccade',
+                'onset': np.arange(0, 901, 100),
+                'offset': np.arange(10, 911, 100),
+            },
+            schema={'type': pl.Utf8, 'onset': pl.Int64, 'offset': pl.Int64},
+        )
+        event_dfs.append(event_df)
+
+    create_event_files_from_fileinfo(event_dfs, fileinfo, rootpath / 'events')
+
+    return {
+        'init_kwargs': {
+            'root': rootpath,
+            'experiment': Experiment(
+                screen_width_px=1280,
+                screen_height_px=1024,
+                screen_width_cm=38,
+                screen_height_cm=30.2,
+                distance_cm=68,
+                origin='lower left',
+                sampling_rate=1000,
+            ),
+            'filename_regex': r'(?P<subject_id>\d+).' + raw_fileformat,
+            'filename_regex_dtypes': {'subject_id': pl.Int64},
+        },
+        'fileinfo': fileinfo,
+        'raw_gaze_dfs': gaze_dfs,
+        'preprocessed_gaze_dfs': preprocessed_gaze_dfs,
+        'event_dfs': event_dfs,
+    }
+
+
+@pytest.fixture(name='dataset_configuration', params=['Toy'])
+def fixture_dataset(request, tmp_path):
+    rootpath = tmp_path
+
+    if request.param == 'Toy':
+        dataset_dict = mock_toy(rootpath, raw_fileformat='csv')
+    elif request.param == 'ToyMat':
+        dataset_dict = mock_toy(rootpath, raw_fileformat='mat')
+    else:
+        raise ValueError(f'{request.param} not supported as dataset mock')
+
+    yield dataset_dict
+
+
+def test_load_correct_fileinfo(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+
+    expected_fileinfo = dataset_configuration['fileinfo']
+    assert_frame_equal(dataset.fileinfo, expected_fileinfo)
+
+
+def test_load_correct_raw_gaze_dfs(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+
+    expected_gaze_dfs = dataset_configuration['raw_gaze_dfs']
+    for result_gaze_df, expected_gaze_df in zip(dataset.gaze, expected_gaze_dfs):
+        assert_frame_equal(result_gaze_df, expected_gaze_df)
+
+
+def test_load_correct_preprocessed_gaze_dfs(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(preprocessed=True)
+
+    expected_gaze_dfs = dataset_configuration['preprocessed_gaze_dfs']
+    for result_gaze_df, expected_gaze_df in zip(dataset.gaze, expected_gaze_dfs):
+        assert_frame_equal(result_gaze_df, expected_gaze_df)
+
+
+def test_load_correct_event_dfs(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(events=True)
+
+    expected_event_dfs = dataset_configuration['event_dfs']
+    for result_event_df, expected_event_df in zip(dataset.events, expected_event_dfs):
+        assert_frame_equal(result_event_df, expected_event_df)
+
+
+@pytest.mark.parametrize(
+    'subset, fileinfo_idx',
+    [
+        pytest.param(
+            {'subject_id': 1},
+            [0],
+            id='subset_key_not_in_fileinfo',
+        ),
+        pytest.param(
+            {'subject_id': [1, 11, 12]},
+            [0, 2, 3],
+            id='subset_key_not_in_fileinfo',
+        ),
+    ],
+)
+def test_load_subset(subset, fileinfo_idx, dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(subset=subset)
+
+    expected_fileinfo = dataset_configuration['fileinfo']
+    expected_fileinfo = expected_fileinfo[fileinfo_idx]
+
+    assert_frame_equal(dataset.fileinfo, expected_fileinfo)
+
+
+@pytest.mark.parametrize(
+    'init_kwargs, exception',
+    [
+        pytest.param(
+            {'root': 'data', 'filename_regex': None},
+            ValueError,
+            id='filename_regex_none_value',
+        ),
+        pytest.param(
+            {'root': 'data', 'filename_regex': 1},
+            TypeError,
+            id='filename_regex_wrong_type',
+        ),
+    ],
+)
+def test_init_exceptions(init_kwargs, exception):
+    with pytest.raises(exception):
+        Dataset(**init_kwargs)
+
+
+@pytest.mark.parametrize(
+    'init_kwargs, load_kwargs, exception',
+    [
+        pytest.param(
+            {'root': '/not/a/real/path'},
+            {},
+            RuntimeError,
+            id='no_files_present',
+        ),
+        pytest.param(
+            {},
+            {'subset': 1},
+            TypeError,
+            id='subset_no_dict',
+        ),
+        pytest.param(
+            {},
+            {'subset': {1: 1}},
+            TypeError,
+            id='subset_no_str_key',
+        ),
+        pytest.param(
+            {},
+            {'subset': {'unknown': 1}},
+            ValueError,
+            id='subset_key_not_in_fileinfo',
+        ),
+        pytest.param(
+            {},
+            {'subset': {'subject_id': None}},
+            TypeError,
+            id='subset_value_invalid_type',
+        ),
+    ],
+)
+def test_load_exceptions(init_kwargs, load_kwargs, exception, dataset_configuration):
+    init_kwargs = {**dataset_configuration['init_kwargs'], **init_kwargs}
+    dataset = Dataset(**init_kwargs)
+
+    with pytest.raises(exception):
+        dataset.load(**load_kwargs)
+
+
+@pytest.mark.parametrize(
+    'exception',
+    [
+        pytest.param(RuntimeError, id='matlab_dataset'),
+    ],
+)
+@pytest.mark.parametrize('dataset_configuration', ['ToyMat'], indirect=['dataset_configuration'])
+def test_load_mat_file_exception(exception, dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+
+    with pytest.raises(exception):
+        dataset.load()
+
+
+def test_pix2deg(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+
+    original_schema = dataset.gaze[0].schema
+
+    dataset.pix2deg()
+
+    expected_schema = {
+        **original_schema,
+        'x_left_dva': pl.Float64,
+        'y_left_dva': pl.Float64,
+        'x_right_dva': pl.Float64,
+        'y_right_dva': pl.Float64,
+    }
+
+    for result_gaze_df in dataset.gaze:
+        assert result_gaze_df.schema == expected_schema
+
+
+def test_pos2vel(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+
+    original_schema = dataset.gaze[0].schema
+
+    dataset.pos2vel()
+
+    expected_schema = {
+        **original_schema,
+        'x_left_vel': pl.Float64,
+        'y_left_vel': pl.Float64,
+        'x_right_vel': pl.Float64,
+        'y_right_vel': pl.Float64,
+    }
+
+    for result_gaze_df in dataset.gaze:
+        assert result_gaze_df.schema == expected_schema
+
+
+@pytest.mark.parametrize(
+    'detect_event_kwargs',
+    [
+        pytest.param(
+            {
+                'method': microsaccades,
+                'threshold': 1,
+                'eye': 'auto',
+            },
+            id='1',
+        ),
+        pytest.param(
+            {
+                'method': microsaccades,
+                'threshold': 1,
+                'eye': 'left',
+            },
+            id='2',
+        ),
+        pytest.param(
+            {
+                'method': microsaccades,
+                'threshold': 1,
+                'eye': 'right',
+            },
+            id='3',
+        ),
+    ],
+)
+def test_detect_events(detect_event_kwargs, dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+    dataset.pos2vel()
+    dataset.detect_events(**detect_event_kwargs)
+
+    for result_event_df in dataset.events:
+        assert result_event_df.schema == Saccade.schema
+
+
+@pytest.mark.parametrize(
+    'detect_event_kwargs_1, detect_event_kwargs_2, expected_schema',
+    [
+        pytest.param(
+            {
+                'method': microsaccades,
+                'threshold': 1,
+                'eye': 'auto',
+            },
+            {
+                'method': microsaccades,
+                'threshold': 1,
+                'eye': 'auto',
+            },
+            Saccade.schema,
+            id='two-saccade-runs',
+        ),
+        pytest.param(
+            {
+                'method': microsaccades,
+                'threshold': 1,
+                'eye': 'left',
+            },
+            {
+                'method': ivt,
+                'velocity_threshold': 1,
+                'minimum_duration': 1,
+            },
+            {**Saccade.schema, **Fixation.schema},
+            id='one-saccade-one-fixation-run',
+        ),
+    ],
+)
+def test_detect_events_multiple_calls(
+    detect_event_kwargs_1, detect_event_kwargs_2,
+    expected_schema, dataset_configuration,
+):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+    dataset.pos2vel()
+    dataset.detect_events(**detect_event_kwargs_1)
+    dataset.detect_events(**detect_event_kwargs_2)
+
+    for result_event_df in dataset.events:
+        assert result_event_df.schema == expected_schema
 
 
 @pytest.mark.parametrize(
@@ -61,7 +504,6 @@ from pymovements.events.events import Event
     ],
 )
 def test_clear_events(events_init, events_expected):
-    """Test if idt detects fixations."""
     dataset = Dataset(root='data')
     dataset.events = events_init
     dataset.clear_events()
@@ -72,3 +514,253 @@ def test_clear_events(events_init, events_expected):
     else:
         for events_df_result, events_df_expected in zip(dataset.events, events_expected):
             assert_frame_equal(events_df_result, events_df_expected)
+
+
+@pytest.mark.parametrize(
+    'detect_event_kwargs, events_dirname, expected_save_dirpath',
+    [
+        pytest.param(
+            {'method': microsaccades, 'threshold': 1, 'eye': 'auto'},
+            None,
+            'events',
+            id='none_dirname',
+        ),
+        pytest.param(
+            {'method': microsaccades, 'threshold': 1, 'eye': 'auto'},
+            'events_test',
+            'events_test',
+            id='explicit_dirname',
+        ),
+    ],
+)
+def test_save_events(
+    detect_event_kwargs, events_dirname, expected_save_dirpath,
+    dataset_configuration,
+):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+    dataset.pos2vel()
+    dataset.detect_events(**detect_event_kwargs)
+
+    if events_dirname is None:
+        events_dirname = 'events'
+    shutil.rmtree(dataset.path / Path(events_dirname), ignore_errors=True)
+    shutil.rmtree(dataset.path / Path(expected_save_dirpath), ignore_errors=True)
+    dataset.save_events(events_dirname)
+
+    assert (dataset.path / expected_save_dirpath).is_dir(), (
+        f'data was not written to {dataset.path / Path(expected_save_dirpath)}'
+    )
+
+
+@pytest.mark.parametrize(
+    'preprocessed_dirname, expected_save_dirpath',
+    [
+        pytest.param(
+            None,
+            'preprocessed',
+            id='none_dirname',
+        ),
+        pytest.param(
+            'preprocessed_test',
+            'preprocessed_test',
+            id='explicit_dirname',
+        ),
+    ],
+)
+def test_save_preprocessed(preprocessed_dirname, expected_save_dirpath, dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+    dataset.pos2vel()
+
+    if preprocessed_dirname is None:
+        preprocessed_dirname = 'preprocessed'
+    shutil.rmtree(dataset.path / Path(preprocessed_dirname), ignore_errors=True)
+    shutil.rmtree(dataset.path / Path(expected_save_dirpath), ignore_errors=True)
+    dataset.save_preprocessed(preprocessed_dirname)
+
+    assert (dataset.path / expected_save_dirpath).is_dir(), (
+        f'data was not written to {dataset.path / Path(expected_save_dirpath)}'
+    )
+
+
+@pytest.mark.parametrize(
+    'expected_save_preprocessed_path, expected_save_events_path, save_kwargs',
+    [
+        pytest.param(
+            'preprocessed',
+            'events',
+            {},
+            id='none_dirname',
+        ),
+        pytest.param(
+            'preprocessed',
+            'events',
+            {'verbose': 2},
+            id='verbose_equals_2',
+        ),
+        pytest.param(
+            'preprocessed_test',
+            'events',
+            {'preprocessed_dirname': 'preprocessed_test'},
+            id='none_dirname',
+        ),
+        pytest.param(
+            'preprocessed',
+            'events_test',
+            {'events_dirname': 'events_test'},
+            id='none_dirname',
+        ),
+    ],
+)
+def test_save(
+    expected_save_preprocessed_path, expected_save_events_path,
+    save_kwargs, dataset_configuration,
+):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+    dataset.pos2vel()
+
+    detect_events_kwargs = {'method': microsaccades, 'threshold': 1, 'eye': 'auto'}
+    dataset.detect_events(**detect_events_kwargs)
+
+    preprocessed_dirname = save_kwargs.get('preprocessed_dirname', 'preprocessed')
+    events_dirname = save_kwargs.get('events_dirname', 'events')
+
+    shutil.rmtree(dataset.path / Path(preprocessed_dirname), ignore_errors=True)
+    shutil.rmtree(dataset.path / Path(expected_save_preprocessed_path), ignore_errors=True)
+    shutil.rmtree(dataset.path / Path(events_dirname), ignore_errors=True)
+    shutil.rmtree(dataset.path / Path(expected_save_events_path), ignore_errors=True)
+    dataset.save(**save_kwargs)
+
+    assert (dataset.path / Path(expected_save_preprocessed_path)).is_dir(), (
+        f'data was not written to {dataset.path / Path(expected_save_events_path)}'
+    )
+    assert (dataset.path / Path(expected_save_events_path)).is_dir(), (
+        f'data was not written to {dataset.path / Path(expected_save_events_path)}'
+    )
+
+
+@pytest.mark.parametrize(
+    'init_kwargs, expected_paths',
+    [
+        pytest.param(
+            {'root': '/data/set/path'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/'),
+                'raw': Path('/data/set/path/raw'),
+                'preprocessed': Path('/data/set/path/preprocessed'),
+                'events': Path('/data/set/path/events'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': '.'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/'),
+                'raw': Path('/data/set/path/raw'),
+                'preprocessed': Path('/data/set/path/preprocessed'),
+                'events': Path('/data/set/path/events'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': 'dataset'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'raw': Path('/data/set/path/dataset/raw'),
+                'preprocessed': Path('/data/set/path/dataset/preprocessed'),
+                'events': Path('/data/set/path/dataset/events'),
+            },
+        ),
+        pytest.param(
+            {
+                'root': '/data/set/path', 'dataset_dirname': 'dataset',
+                'events_dirname': 'custom_events',
+            },
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'raw': Path('/data/set/path/dataset/raw'),
+                'preprocessed': Path('/data/set/path/dataset/preprocessed'),
+                'events': Path('/data/set/path/dataset/custom_events'),
+            },
+        ),
+        pytest.param(
+            {
+                'root': '/data/set/path', 'dataset_dirname': 'dataset',
+                'preprocessed_dirname': 'custom_preprocessed',
+            },
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'raw': Path('/data/set/path/dataset/raw'),
+                'preprocessed': Path('/data/set/path/dataset/custom_preprocessed'),
+                'events': Path('/data/set/path/dataset/events'),
+            },
+        ),
+        pytest.param(
+            {
+                'root': '/data/set/path', 'dataset_dirname': 'dataset',
+                'raw_dirname': 'custom_raw',
+            },
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'raw': Path('/data/set/path/dataset/custom_raw'),
+                'preprocessed': Path('/data/set/path/dataset/preprocessed'),
+                'events': Path('/data/set/path/dataset/events'),
+            },
+        ),
+    ],
+)
+def test_paths(init_kwargs, expected_paths):
+    dataset = Dataset(**init_kwargs)
+
+    assert dataset.root == expected_paths['root']
+    assert dataset.path == expected_paths['path']
+    assert dataset.raw_rootpath == expected_paths['raw']
+    assert dataset.preprocessed_rootpath == expected_paths['preprocessed']
+    assert dataset.events_rootpath == expected_paths['events']
+
+
+@pytest.mark.parametrize(
+    'new_fileinfo, exception',
+    [
+        pytest.param(None, AttributeError),
+        pytest.param([], AttributeError),
+    ],
+)
+def test_check_fileinfo(new_fileinfo, exception):
+    dataset = Dataset('data')
+
+    dataset.fileinfo = new_fileinfo
+
+    with pytest.raises(exception):
+        dataset._check_fileinfo()
+
+
+@pytest.mark.parametrize(
+    'new_gaze, exception',
+    [
+        pytest.param(None, AttributeError),
+        pytest.param([], AttributeError),
+    ],
+)
+def test_check_gaze_dataframe(new_gaze, exception):
+    dataset = Dataset('data')
+
+    dataset.gaze = new_gaze
+
+    with pytest.raises(exception):
+        dataset._check_gaze_dataframe()
+
+
+def test_check_experiment():
+    dataset = Dataset('data')
+    with pytest.raises(AttributeError):
+        dataset._check_experiment()
diff --git a/tests/datasets/test_gazebase.py b/tests/datasets/test_gazebase.py
new file mode 100644
index 0000000..53ad744
--- /dev/null
+++ b/tests/datasets/test_gazebase.py
@@ -0,0 +1,70 @@
+# Copyright (c) 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.
+"""Test all functionality in pymovements.datasets.gazebase."""
+from pathlib import Path
+
+import pytest
+
+from pymovements.datasets.gazebase import GazeBase
+
+
+@pytest.mark.parametrize(
+    'init_kwargs, expected_paths',
+    [
+        pytest.param(
+            {'root': '/data/set/path'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/GazeBase'),
+                'download': Path('/data/set/path/GazeBase/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': '.'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/'),
+                'download': Path('/data/set/path/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': 'dataset'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'download': Path('/data/set/path/dataset/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'downloads_dirname': 'custom_download_dirname'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/GazeBase'),
+                'download': Path('/data/set/path/GazeBase/custom_download_dirname'),
+            },
+        ),
+    ],
+)
+def test_paths(init_kwargs, expected_paths):
+    dataset = GazeBase(**init_kwargs)
+
+    assert dataset.root == expected_paths['root']
+    assert dataset.path == expected_paths['path']
+    assert dataset.downloads_rootpath == expected_paths['download']
diff --git a/tests/datasets/test_judo1000.py b/tests/datasets/test_judo1000.py
new file mode 100644
index 0000000..f569949
--- /dev/null
+++ b/tests/datasets/test_judo1000.py
@@ -0,0 +1,70 @@
+# Copyright (c) 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.
+"""Test all functionality in pymovements.datasets.judo1000."""
+from pathlib import Path
+
+import pytest
+
+from pymovements.datasets.judo1000 import JuDo1000
+
+
+@pytest.mark.parametrize(
+    'init_kwargs, expected_paths',
+    [
+        pytest.param(
+            {'root': '/data/set/path'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/JuDo1000'),
+                'download': Path('/data/set/path/JuDo1000/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': '.'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/'),
+                'download': Path('/data/set/path/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': 'dataset'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'download': Path('/data/set/path/dataset/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'downloads_dirname': 'custom_download_dirname'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/JuDo1000'),
+                'download': Path('/data/set/path/JuDo1000/custom_download_dirname'),
+            },
+        ),
+    ],
+)
+def test_paths(init_kwargs, expected_paths):
+    dataset = JuDo1000(**init_kwargs)
+
+    assert dataset.root == expected_paths['root']
+    assert dataset.path == expected_paths['path']
+    assert dataset.downloads_rootpath == expected_paths['download']
diff --git a/tests/datasets/test_public_dataset.py b/tests/datasets/test_public_dataset.py
new file mode 100644
index 0000000..df19a82
--- /dev/null
+++ b/tests/datasets/test_public_dataset.py
@@ -0,0 +1,80 @@
+# Copyright (c) 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.
+"""Test all functionality in pymovements.datasets.public_dataset."""
+from pathlib import Path
+
+import pytest
+
+from pymovements.datasets.public_dataset import PublicDataset
+
+
+@pytest.mark.parametrize(
+    'init_kwargs, expected_paths',
+    [
+        pytest.param(
+            {'root': '/data/set/path'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/CustomPublicDataset'),
+                'download': Path('/data/set/path/CustomPublicDataset/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': '.'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/'),
+                'download': Path('/data/set/path/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': 'dataset'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'download': Path('/data/set/path/dataset/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'downloads_dirname': 'custom_download_dirname'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/CustomPublicDataset'),
+                'download': Path('/data/set/path/CustomPublicDataset/custom_download_dirname'),
+            },
+        ),
+    ],
+)
+def test_paths(init_kwargs, expected_paths):
+    class CustomPublicDataset(PublicDataset):
+        _mirrors = ['https://www.example.com/']
+        _resources = [
+            {
+                'resource': 'relative_path_to_resource',
+                'filename': 'example_dataset.zip',
+                'md5': 'md5md5md5md5md5md5md5md5md5md5md',
+            },
+        ]
+
+    dataset = CustomPublicDataset(**init_kwargs)
+
+    assert dataset.root == expected_paths['root']
+    assert dataset.path == expected_paths['path']
+    assert dataset.downloads_rootpath == expected_paths['download']
diff --git a/tests/events/test_engbert.py b/tests/events/test_engbert.py
index f31be5e..8786039 100644
--- a/tests/events/test_engbert.py
+++ b/tests/events/test_engbert.py
@@ -125,6 +125,50 @@ def test_microsaccades_raises_error(kwargs, expected):
             ),
             id='four_steps_two_saccades',
         ),
+        pytest.param(
+            {
+                'positions': step_function(length=100, steps=[0], values=[(0, 0)]),
+                'velocities': step_function(
+                    length=100,
+                    steps=[20, 25, 28, 30, 70, 80],
+                    values=[(9, 9), (np.nan, np.nan), (9, 9), (0, 0), (9, 9), (0, 0)],
+                    start_value=(0, 0),
+                ),
+                'threshold': 1,
+                'include_nan': True,
+            },
+            pl.DataFrame(
+                {
+                    'type': 'saccade',
+                    'onset': [20, 70],
+                    'offset': [29, 79],
+                },
+                schema=Saccade.schema,
+            ),
+            id='two_saccades_nan_delete_ending_leading_nan',
+        ),
+        pytest.param(
+            {
+                'positions': step_function(length=100, steps=[0], values=[(0, 0)]),
+                'velocities': step_function(
+                    length=100,
+                    steps=[20, 25, 28, 30, 70, 80],
+                    values=[(9, 9), (np.nan, np.nan), (9, 9), (0, 0), (9, 9), (0, 0)],
+                    start_value=(0, 0),
+                ),
+                'threshold': 1,
+                'minimum_duration': 1,
+            },
+            pl.DataFrame(
+                {
+                    'type': 'saccade',
+                    'onset': [20, 28, 70],
+                    'offset': [24, 29, 79],
+                },
+                schema=Saccade.schema,
+            ),
+            id='three_saccades_nan_delete_ending_leading_nan',
+        ),
     ],
 )
 def test_microsaccades_detects_saccades(kwargs, expected):
diff --git a/tests/events/test_idt.py b/tests/events/test_idt.py
index 216ddb7..af3450f 100644
--- a/tests/events/test_idt.py
+++ b/tests/events/test_idt.py
@@ -181,6 +181,53 @@ def test_idt_raises_error(kwargs, expected_error):
             ),
             id='three_steps_two_fixations',
         ),
+        pytest.param(
+            {
+                'positions': step_function(
+                    length=100, steps=[10, 20, 90],
+                    values=[
+                        (np.nan, np.nan), (0, 0),
+                        (np.nan, np.nan),
+                    ],
+                ),
+                'dispersion_threshold': 1,
+                'minimum_duration': 1,
+            },
+            pl.DataFrame(
+                {
+                    'type': 'fixation',
+                    'onset': [0, 20],
+                    'offset': [9, 89],
+                    'position': [(0.0, 0.0), (0.0, 0.0)],
+                },
+                schema=Fixation.schema,
+            ),
+            id='two_fixations_nan_delete_leading_ending',
+        ),
+        pytest.param(
+            {
+                'positions': step_function(
+                    length=100, steps=[10, 20, 90],
+                    values=[
+                        (np.nan, np.nan), (0, 0),
+                        (np.nan, np.nan),
+                    ],
+                ),
+                'dispersion_threshold': 1,
+                'minimum_duration': 1,
+                'include_nan': True,
+            },
+            pl.DataFrame(
+                {
+                    'type': 'fixation',
+                    'onset': [0],
+                    'offset': [89],
+                    'position': [(0.0, 0.0)],
+                },
+                schema=Fixation.schema,
+            ),
+            id='one_fixation_nan_delete_leading_ending',
+        ),
     ],
 )
 def test_idt_detects_fixations(kwargs, expected):
diff --git a/tests/events/test_ivt.py b/tests/events/test_ivt.py
index bc80ef7..6cb755f 100644
--- a/tests/events/test_ivt.py
+++ b/tests/events/test_ivt.py
@@ -213,6 +213,53 @@ def test_ivt_raise_error(kwargs, expected_error):
             ),
             id='three_steps_two_fixations',
         ),
+        pytest.param(
+            {
+                'positions': step_function(
+                    length=100, steps=[10, 20, 90],
+                    values=[
+                        (np.nan, np.nan),
+                        (0, 0), (np.nan, np.nan),
+                    ],
+                ),
+                'velocity_threshold': 1,
+                'minimum_duration': 1,
+            },
+            pl.DataFrame(
+                {
+                    'type': 'fixation',
+                    'onset': [0, 21],
+                    'offset': [9, 89],
+                    'position': [(0.0, 0.0), (0.0, 0.0)],
+                },
+                schema=Fixation.schema,
+            ),
+            id='two_fixations_nan_remove_leading_ending',
+        ),
+        pytest.param(
+            {
+                'positions': step_function(
+                    length=100, steps=[10, 20, 90],
+                    values=[
+                        (np.nan, np.nan),
+                        (0, 0), (np.nan, np.nan),
+                    ],
+                ),
+                'velocity_threshold': 1,
+                'minimum_duration': 1,
+                'include_nan': True,
+            },
+            pl.DataFrame(
+                {
+                    'type': 'fixation',
+                    'onset': [0],
+                    'offset': [89],
+                    'position': [(0.0, 0.0)],
+                },
+                schema=Fixation.schema,
+            ),
+            id='one_fixations_nan_remove_leading_ending',
+        ),
     ],
 )
 def test_ivt_detects_fixations(kwargs, expected):
diff --git a/tests/synthetic/test_step_function.py b/tests/synthetic/test_step_function.py
index 09940c1..5774cbd 100644
--- a/tests/synthetic/test_step_function.py
+++ b/tests/synthetic/test_step_function.py
@@ -99,6 +99,11 @@ from pymovements.synthetic import step_function
             {'exception': ValueError},
             id='negative_noise_raises_value_error',
         ),
+        pytest.param(
+            {'length': 4, 'steps': [2], 'values': [(np.nan, np.nan)], 'start_value': (0, 0)},
+            {'dimension': (4, 2)},
+            id='length_5_2_nan_value',
+        ),
     ],
 )
 def test_step_function(params, expected):
@@ -106,6 +111,10 @@ def test_step_function(params, expected):
         with pytest.raises(expected['exception']):
             step_function(**params)
         return
+    if 'dimension' in expected:
+        arr = step_function(**params)
+        assert expected['dimension'] == arr.shape
+        return
 
     arr = step_function(**params)
     assert np.array_equal(arr, expected['value']), f"arr = {arr}, expected = {expected['value']}"
diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py
index 08c20aa..65213cd 100644
--- a/tests/utils/test_checks.py
+++ b/tests/utils/test_checks.py
@@ -20,12 +20,132 @@
 """
 Test pymovements checks.
 """
+import numpy as np
+import pytest
 
+from pymovements.utils.checks import check_nan_both_channels
+from pymovements.utils.checks import check_no_zeros
+from pymovements.utils.checks import check_shapes_positions_velocities
 
-def test_check_no_zeros_exception():
-    # use fixtures: (single variable, list, np.array)
-    # check both valid and invalid inputs with the help of a second variable
+
+@pytest.mark.parametrize(
+    'variable, expected_error',
+    [
+        pytest.param(5, None, id='non_zero_single_variable_raises_no_error'),
+        pytest.param(0, ValueError, id='zero_single_variable_raises_value_error'),
+        pytest.param([1, 2, 3], None, id='non_zero_list_raises_no_error'),
+        pytest.param([1, 0, 3], ValueError, id='zero_list_raises_value_error'),
+        pytest.param(np.array([1, 2, 3]), None, id='non_zero_np_array_raises_no_error'),
+        pytest.param(np.array([1, 0, 3]), ValueError, id='zero_np_array_raises_value_error'),
+    ],
+)
+def test_check_no_zeros_raises_error(variable, expected_error):
+    """
+    Test that check_no_zeros() only raises an Exception if there are zeros in the input array.
+    """
+    if expected_error is None:
+        check_no_zeros(variable)
+    else:
+        with pytest.raises(expected_error):
+            check_no_zeros(variable)
+
+
+@pytest.mark.parametrize(
+    'arr, expected_error',
+    [
+        pytest.param(
+            np.array([[1, 2], [3, 4]]),
+            None,
+            id='no_nans_raises_no_error',
+        ),
+        pytest.param(
+            np.array([[1, 2], [np.nan, np.nan]]),
+            None,
+            id='nans_same_time_steps_raises_no_error',
+        ),
+        pytest.param(
+            np.array([[np.nan, 2], [np.nan, 4]]),
+            ValueError,
+            id='nans_different_time_steps_raises_value_error',
+        ),
+        pytest.param(
+            np.array([[np.nan, 2], [3, 4]]),
+            ValueError,
+            id='nans_only_left_channel_raises_value_error',
+        ),
+        pytest.param(
+            np.array([[1, np.nan], [3, 4]]),
+            ValueError,
+            id='nans_only_right_channel_raises_value_error',
+        ),
+    ],
+)
+def test_check_nan_both_channels_raises_error(arr, expected_error):
+    """
+    Test that check_nan_both_channels() only raises an Exception if all nans
+    occur at the same time step for both channels.
+    """
+    if expected_error is None:
+        check_nan_both_channels(arr)
+    else:
+        with pytest.raises(expected_error):
+            check_nan_both_channels(arr)
+
+
+# Test check_shapes_positions_velocities
+@pytest.mark.parametrize(
+    'kwargs, expected_error',
+    [
+        pytest.param(
+            {
+                'positions': np.array([[1, 2], [3, 4]]),
+                'velocities': np.array([[1, 2], [3, 4]]),
+            },
+            None,
+            id='positions_and_velocities_shape_N_2_raises_no_error',
+        ),
+        pytest.param(
+            {
+                'positions': np.array([[1, 2], [3, 4]]),
+                'velocities': np.array([1, 2, 3, 4]),
+            },
+            ValueError,
+            id='positions_shape_N_2_velocities_not_shape_N_2_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'positions': np.array([1, 2, 3, 4]),
+                'velocities': np.array([[1, 2], [3, 4]]),
+            },
+            ValueError,
+            id='positions_not_shape_N_2_velocities_shape_N_2_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'positions': np.array([1, 2, 3, 4]),
+                'velocities': np.array([1, 2, 3, 4]),
+            },
+            ValueError,
+            id='positions_and_velocities_not_shape_N_2_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'positions': np.array([[1, 2], [3, 4]]),
+                'velocities': np.array([[1, 2], [3, 4], [5, 6]]),
+            },
+            ValueError,
+            id='positions_and_velocities_N_2_but_different_lengths_raises_value_error',
+        ),
+    ],
+)
+def test_check_shapes_positions_velocities_raises_error(kwargs, expected_error):
     """
-    Test that check_no_zeros() only raises an Exception iff there are zeros in the input array.
+    Test that check_shapes_positions_velocities() only raises an Exception if
+    the shapes of the positions and velocities are not (N, 2) or if the lengths
+    of the positions and velocities are not equal.
     """
-    return
+    if expected_error is None:
+        check_shapes_positions_velocities(**kwargs)
+    else:
+        with pytest.raises(expected_error):
+            check_shapes_positions_velocities(**kwargs)
diff --git a/tests/utils/test_filters.py b/tests/utils/test_filters.py
new file mode 100644
index 0000000..2c5ea91
--- /dev/null
+++ b/tests/utils/test_filters.py
@@ -0,0 +1,83 @@
+# Copyright (c) 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.
+"""
+Test pymovements filters.
+"""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from pymovements.utils.filters import events_split_nans
+from pymovements.utils.filters import filter_candidates_remove_nans
+
+
+@pytest.mark.parametrize(
+    'params, expected',
+    [
+        pytest.param(
+            {
+                'candidates': [
+                    [0, 1, 2, 3, 4],
+                    [5, 6, 7, 8],
+                ],
+                'values': np.array([
+                    (np.nan, np.nan), (0, 0),
+                    (0, 0), (0, 0),
+                    (np.nan, np.nan),
+                    (np.nan, np.nan),
+                    (0, 0), (0, 0),
+                    (0, 0),
+                ]),
+            },
+            {'values_filter': [np.array([1, 2, 3]), np.array([6, 7, 8])]},
+            id='test_filters',
+        ),
+        pytest.param(
+            {
+                'candidates': [[0, 1, 2, 3, 4, 5, 6, 7]],
+                'values': np.array([
+                    (0, 0),
+                    (0, 0), (0, 0),
+                    (np.nan, np.nan),
+                    (np.nan, np.nan),
+                    (0, 0), (0, 0),
+                    (0, 0),
+                ]),
+            },
+            {'values_split': [np.array([0, 1, 2]), np.array([5, 6, 7])]},
+            id='test_events_split',
+        ),
+    ],
+)
+def test_filters(params, expected):
+    if 'values_filter' in expected:
+        results = filter_candidates_remove_nans(
+            params['candidates'],
+            params['values'],
+        )
+        assert np.all(np.array(expected['values_filter']) == np.array(results))
+
+    if 'values_split' in expected:
+        results = events_split_nans(
+            params['candidates'],
+            params['values'],
+        )
+        assert np.all(np.array(expected['values_split']) == np.array(results))