Repository: aeye-lab/pymovements
Pull request #64 (2022-11-06 15:39:02)

Parametrize tests for transformation functions
## Description

Merged the single tests for `transforms.pos2vel` and `transforms.deg2vel` into one test by pytest parametrization:

## Implemented changes
- `test_pix2deg_raise_error` function for `pix2deg` raising error `expected_error` with input `kwargs`
- `test_pos2vel_raise_error` function for `pos2vel` raising error `expected_error` with input `kwargs`
- `test_pix2deg_equals` to check if `pix2deg` returns `expected_value` with input `kwargs` 
- `test_pos2vel_returns` to check if `pos2vel` returns `expected_value` with input `kwargs` and calculation method `method`




jakobchwastek: Should we set a similar name for return tests `test_pix2deg_equals` and `test_pos2vel_returns` ?
dkrako: > Should we set a similar name for return tests `test_pix2deg_equals` and `test_pos2vel_returns` ?

Yes, good idea.
Let's name it `test_pix2deg_returns` and `test_pos2vel_returns` then.

Also rename the functions which test for exception raising:
`test_pix2deg_raise_error` to `test_pix2deg_raises_error` and the same for the one for `pos2vel`.

There are three small flake8 errors left.

Otherwise, this looks nice and can be merged after these small fixes.

Patch:
diff --git a/.github/ISSUE_TEMPLATE/ISSUE.md b/.github/ISSUE_TEMPLATE/ISSUE.md
index e02f6f0..983c676 100644
--- a/.github/ISSUE_TEMPLATE/ISSUE.md
+++ b/.github/ISSUE_TEMPLATE/ISSUE.md
@@ -6,13 +6,12 @@ labels: bug
 assignees:
 ---
 
-## Expected Behavior
-
-- Insert a clear and concise description of what the issue or bug is.
 
 ## Current Behavior
+Insert a clear and concise description of what the issue or bug is.
 
-What is the current behavior?
+## Expected Behavior
+What behavior is actually expected?
 
 ## Minimum acceptance criteria
 Specify what is necessary for the issue to be closed.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 97f5e02..6eae96d 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -4,13 +4,15 @@ Please include a clear and concise description of the change and which issue is
 Please also include excepted improvements, relevant motivation and context.
 List any dependencies that are required for this change.
 
-Fixes # (issue) (see: [issues](https://github.com/aeye-lab/pymovements/issues))
+Fixes issue #
 
 ## Implemented changes
-- Insert a description of the changes implemented in the pull request.
-  - [ ] Change 1
-  - [ ] Change 2
-  - [ ] Change 3
+
+Insert a description of the changes implemented in the pull request.
+
+- [ ] Change 1
+- [ ] Change 2
+- [ ] Change 3
 
 ## Type of change
 
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 83fb4eb..e362907 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,6 +14,7 @@ repos:
     rev: v3.8.2
     hooks:
     -   id: reorder-python-imports
+        args: [--application-directories=.:src]
 -   repo: https://github.com/asottile/pyupgrade
     rev: v2.37.3
     hooks:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..b147977
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,261 @@
+# Contributing to pymovements
+Thank you for taking your time to contribute to pymovements!
+
+We encourage you to report any bugs or contribute to new features, optimisations or documentation.
+
+Here we give you an overview of the workflow and best practices for contributing
+to pymovements.
+
+**Questions:** If you have any developer-related questions, please [open an issue](
+https://github.com/aeye-lab/pymovements/issues/new/choose) or write us at
+[daniel.krakowczyk@uni-potsdam.de](mailto:daniel.krakowczyk@uni-potsdam.de).
+
+
+## Table of Contents
+- [Code of Conduct](#code-of-conduct)
+- [Reporting Bugs](#reporting-bugs)
+- [First-time Contributors](#first-time-contributors)
+- [Getting Started](#getting-started)
+  - [Development Installation](#development-installation)
+  - [Creating a Branch](#creating-a-branch)
+  - [Code Style](#code-style)
+  - [Testing](#testing)
+  - [Documentation](#documentation)
+  - [Pull Requests](#pull-requests)
+  - [Continuous Integration](#continuous-integration)
+- [Core Developer Guidelines](#core-developer-guidelines)
+- [License](#license)
+- [Questions](#questions)
+
+
+## Code of Conduct
+
+Everyone participating in the pymovements project, and in particular in our issue tracker and pull
+requests, is expected to treat other people with respect and more generally to follow the guidelines
+articulated in the [Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/).
+
+
+## Reporting Bugs
+
+If you discover a bug, as a first step please check the existing
+[Issues](https://github.com/aeye-lab/pymovements/issues) to see if this bug has already been
+reported.
+
+In case the bug has not been reported yet, please do the following:
+
+- [Open an issue](https://github.com/aeye-lab/pymovements/issues/new?labels=bug&template=ISSUE.md).
+- Add a descriptive title to the issue and write a short summary of the problem.
+- We provide you with a default template to guide you through a typical reporting process.
+- Adding more context, including error messages and references to the problematic parts of the code,
+would be very helpful to us.
+
+Once a bug is reported, our development team will try to address the issue as quickly as possible.
+
+
+## First-time Contributors
+
+If you're looking for things to help with, try browsing our [issue tracker](
+https://github.com/aeye-lab/pymovements/issues) first. In particular, look for:
+
+- [good first issues](https://github.com/aeye-lab/pymovements/labels/good-first-issue)
+- [documentation issues](https://github.com/aeye-lab/pymovements/labels/documentation)
+
+You do not need to ask for permission to work on any of these issues. The current status of the
+issue will let you know if someone else is or was already working on it.
+
+To get help fixing a specific issue, it's often best to comment on the issue itself. You're much
+more likely to get targeted help if you provide details about what you've tried and where you've looked.
+
+To start out with developing, [install the dependencies](#development-installation) and
+[create a branch](#creating-a-branch) for your contribution.
+
+
+Create a [pull request](#pull-requests) when you feel confident to publish your progress. Don't
+hesitate if it's a work in progress, we can give you early feedback on your work.
+If you can, try to add [unit tests](#testing) early on to verify correctness.
+
+
+## Getting Started
+
+This is a general guide to contributing changes to pymovements.
+
+Before you start developing, make sure to read our [documentation](
+https://pymovements.readthedocs.io/) first.
+
+
+### Development Installation
+
+Make sure to install the latest pymovements version from the main branch.
+
+```bash
+git clone https://github.com/aeye-lab/pymovements.git
+cd pymovements
+pip install -e .
+```
+
+
+### Creating a Branch
+
+Before you start making changes to the code, create a local branch from the latest version of the
+`main` branch.
+
+```bash
+git checkout main
+git pull origin main
+git checkout -b feature/your-feature-branch
+```
+
+We do not allow for pushing directly to the `main` branch and merge changes exclusively by
+[pull requests](#pull-requests).
+
+We will squash your commits into a single commit on merge to maintain a clean git history.
+We use a linear git-history, where each commit contains a full feature/bug fix, such that each
+commit represents an executable version. This way you also don't have to worry much about your
+intermediate commits and can focus on getting your work done first.
+
+
+### Code Style
+
+We write our code to follow [PEP-8](https://www.python.org/dev/peps/pep-0008) with a maximum
+line-width of 100 characters. We additionally use type annotations as in [PEP-484](
+https://peps.python.org/pep-0484). For docstrings we use the [numpydoc](
+https://numpydoc.readthedocs.io/en/latest/format.html) formatting standard.
+
+We use [`flake8`](https://pypi.org/project/flake8/) for quick style checks and
+[`pylint`](https://pypi.org/project/pylint/) for thorough style checks and [`mypy`](
+https://pypi.org/project/mypy/) for checking type annotations.
+
+
+### Testing
+
+Tests are written using [Pytest](https://docs.pytest.org) and executed
+in a separate environment using [Tox](https://tox.readthedocs.io/en/latest/).
+
+A full style check and all tests can be run by simply calling `tox` in the repository root.
+```bash
+tox
+```
+Running `tox` the first time in the repository will take a few minutes, as all necessary python
+environments will have to be set up with their dependencies. Runtime should be short on the
+subsequent runs.
+
+If you add a new feature, please also include appropriate tests to verify its intended
+functionality. We try to keep our code coverage close to 100%.
+
+It is possible to limit the scope of testing to specific environments and files, for example, to
+only test transformations using the Python 3.7 environment use:
+```bash
+tox -e py37 tests/test_transformations.py
+```
+
+
+### Documentation
+
+Make sure to add docstrings to every class, method and function that you add to the codebase.
+Docstrings should include a description of all parameters, returns and exceptions. Use the existing
+documentation as an example.
+
+The API-documentation is generated from the numpydoc-style docstring of the respective
+modules/classes/functions by [Sphinx](https://www.sphinx-doc.org).
+You can build the documentation locally using the respective tox environment:
+```bash
+tox -e docs
+```
+It will appear in the `build/docs` directory.
+
+To rebuild the full documentation use
+```bash
+tox -e docs -- -aE
+```
+
+
+### Pull Requests
+
+Once you are ready to publish your changes:
+
+- Create a [pull request (PR)](https://github.com/aeye-lab/pymovements/compare).
+- Provide a summary of the changes you are introducing according to the default template.
+- In case you are resolving an issue, don't forget to add a reference in the description.
+
+The default template is meant as a helper and should guide you through the process of creating a
+pull request. It's also totally fine to submit work in progress, in which case you'll likely be
+asked to make some further changes.
+
+If your change will be a significant amount of work to write, we highly recommend starting by
+opening an issue laying out what you want to do. That lets a conversation happen early in case other
+contributors disagree with what you'd like to do or have ideas that will help you do it.
+
+The best pull requests are focused, clearly describe what they're for and why they're correct, and
+contain tests for whatever changes they make to the code's behavior. As a bonus these are easiest
+for someone to review, which helps your pull request get merged quickly. Standard advice about good
+pull requests for open-source projects applies.
+
+Do not squash your commits after you have submitted a pull request, as this
+erases context during review. We will squash commits when the pull request is ready to be merged.
+
+
+### Continuous Integration
+
+Tests, code style and documentation are all additionally checked using a GitHub Actions
+workflow which executes the appropriate tox environments. Merging of Pull requests will not be
+possible until all checks pass.
+
+
+## Core Developer Guidelines
+
+Core developers should follow these rules when processing pull requests:
+
+- Always wait for tests to pass before merging PRs.
+- Use "[Squash and merge](https://github.com/blog/2141-squash-your-commits)" to merge PRs.
+- Delete branches for merged PRs.
+- Edit the final commit message before merging to conform to the following style (we wish to have a
+clean `git log` output):
+
+```
+Category: Short subject describing changes (#PR-id) (50 characters or less)
+
+- detailed description, wrapped at 72 characters
+- bullet points or sentences are okay
+- all changes should be documented and explained
+- valid categories are, for example:
+    - `Docs` for documentation
+    - `Tests` for tests
+    - `Core` for core changes
+    - `Events` for changes in event detection
+    - `Transforms` for changes in transformations
+    - `Package` for package-related changes, e.g. in setup.cfg
+```
+
+Make sure:
+
+  - that when merging a multi-commit PR the commit message doesn't
+    contain the local history from the committer and the review history from
+    the PR. Edit the message to only describe the end state of the PR.
+  - that there is a *single* newline between subject and description,
+    This way there is a single empty line between commits in `git log`
+    output.
+  - that the maximum subject line length is under 50 characters
+  - that the maximum line length of the commit message is under 72 characters
+  - to capitalize the subject and each paragraph.
+  - that the subject of the commit message has no trailing dot.
+  - to use the imperative mood in the subject line (e.g. "Fix typo in README").
+  - if the PR fixes an issue, that something like "Fixes #xxx." occurs in the body of the message
+(not in the subject).
+  - to use Markdown for formatting.
+
+
+## License
+
+Please note that by contributing to the project you agree that it will be licensed under the
+[License](https://github.com/aeye-lab/pymovements/blob/main/LICENSE) of this project.
+
+If you did not write the code yourself, ensure the existing license is compatible and include the
+license information in the contributed files, or obtain permission from the original author to
+relicense the contributed code.
+
+
+## Questions
+
+If you have any developer-related questions, please [open an issue](
+https://github.com/aeye-lab/pymovements/issues/new/choose) or write us at
+[daniel.krakowczyk@uni-potsdam.de](mailto:daniel.krakowczyk@uni-potsdam.de).
diff --git a/README.md b/README.md
index afee4eb..78b4f26 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,70 @@
-# pymovements
-[![PyPI version](https://badge.fury.io/py/pymovements.svg)](https://badge.fury.io/py/pymovements)
-![Python version](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11-blue.svg)
-[![GitHub license](https://badgen.net/github/license/aeye-lab/pymovements)](https://github.com/aeye-lab/pymovements/blob/master/LICENSE)
+<p align="center"><picture>
+<img width="110%" height="110%" src="./docs/source/_static/logo.svg" alt="pymovements">
+</picture></p>
+
+---
+
+[![PyPI Latest Release](https://img.shields.io/pypi/v/pymovements.svg)](https://pypi.python.org/pypi/pymovements/)
+[![PyPI status](https://img.shields.io/pypi/status/pymovements.svg)](https://pypi.python.org/pypi/pymovements/)
+[![Python version](https://img.shields.io/pypi/pyversions/pymovements.svg)](https://pypi.python.org/pypi/pymovements/)
+![Operating System](https://img.shields.io/badge/os-linux%20%7C%20macOS%20%7C%20windows-blue)
+[![License](https://img.shields.io/pypi/l/pymovements.svg)](https://github.com/aeye-lab/pymovements/blob/master/LICENSE)
+[![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)
-![GitHub issues](https://img.shields.io/github/issues/aeye-lab/pymovements)
-![os](https://img.shields.io/badge/os-win%7CmacOS%7Clinux-brightgreen)
-[![PyPI status](https://img.shields.io/pypi/status/ansicolortags.svg)](https://pypi.python.org/pypi/pymovements/)
+[![PyPI downloads/month](https://img.shields.io/pypi/dm/pymovements.svg)](https://pypistats.org/packages/pymovements)
+
+
+pymovements is an open-source python package for processing eye movement data. It provides a simple
+interface to download publicly available datasets, preprocess gaze data, detect oculomotoric events
+and render plots to visually analyze your results. 
+
+- **Website:** https://github.com/aeye-lab/pymovements
+- **Documentation:** https://pymovements.readthedocs.io
+- **Source code:** https://github.com/aeye-lab/pymovements
+- **Contributing:** https://github.com/aeye-lab/pymovements/blob/main/CONTRIBUTING.md
+- **Bug reports:** https://github.com/aeye-lab/pymovements/issues
+- **PyPI package:** https://pypi.org/project/pymovements
+
+
+## Installation
+
+pymovements can be installed directly from the PyPI repositories:
+
+```bash
+pip install pymovements
+```
+
+To use the latest development version or to try out tutorials, pymovements may be alternatively
+cloned and installed with
+
+```bash
+git clone https://github.com/aeye-lab/pymovements.git
+pip install -e ./pymovements
+```
+
+
+## Contributing
+
+We welcome any sort of contribution to pymovements!
+
+For a detailed guide, please refer to our [CONTRIBUTING.md](CONTRIBUTING.md) first.
+
+If you have any questions, please [open an issue](
+https://github.com/aeye-lab/pymovements/issues/new/choose) or write us at
+[daniel.krakowczyk@uni-potsdam.de](mailto:daniel.krakowczyk@uni-potsdam.de)
+
+
+## Citing
+
+If you are using pymovements in your research, we would be happy if you cite our work by using the
+following BibTex entry:
 
-This is a python package for extracting oculomotoric event information from eye tracking signals.
+```bibtex
+@misc{pymovements,
+  author = {Krakowczyk, Daniel and Reich, David R. and Chwastek, Jakob, Prasse, Paul and Jäger, Lena},
+  title = {pymovements: A Python Package for Processing Eye Movement Data},
+  year = {2023},
+  publisher = {GitHub},
+  howpublished = {\url{https://github.com/aeye-lab/pymovements}},
+}
+```
\ No newline at end of file
diff --git a/docs/source/_static/logo.svg b/docs/source/_static/logo.svg
new file mode 100755
index 0000000..ee2777d
--- /dev/null
+++ b/docs/source/_static/logo.svg
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+        "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="50%" height="100%" viewBox="0 0 1617 371" version="1.1" xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/"
+     style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;"><g><path d="M137.466,182.147c9.067,0 17.1,2.233 24.1,6.7c7,4.467 12.567,10.6 16.7,18.4c4.134,7.8 6.2,16.767 6.2,26.9c0,10.133 -2.066,19.133 -6.2,27c-4.133,7.867 -9.733,14.067 -16.8,18.6c-7.066,4.533 -15,6.8 -23.8,6.8c-4.666,-0 -9.1,-0.7 -13.3,-2.1c-4.2,-1.4 -8.033,-3.3 -11.5,-5.7c-3.466,-2.4 -6.533,-5.233 -9.2,-8.5c-2.666,-3.267 -4.8,-6.833 -6.4,-10.7l3.6,-2.6l0,62.2c0,1.6 -0.566,3 -1.7,4.2c-1.133,1.2 -2.566,1.8 -4.3,1.8c-1.733,-0 -3.166,-0.6 -4.3,-1.8c-1.133,-1.2 -1.7,-2.6 -1.7,-4.2l0,-130.4c0,-1.733 0.534,-3.2 1.6,-4.4c1.067,-1.2 2.534,-1.8 4.4,-1.8c1.734,0 3.167,0.6 4.3,1.8c1.134,1.2 1.7,2.667 1.7,4.4l0,21.6l-2.8,-1.6c1.334,-4.133 3.3,-7.833 5.9,-11.1c2.6,-3.267 5.634,-6.067 9.1,-8.4c3.467,-2.333 7.267,-4.1 11.4,-5.3c4.134,-1.2 8.467,-1.8 13,-1.8Zm-1,11.2c-7.2,0 -13.533,1.767 -19,5.3c-5.466,3.533 -9.766,8.333 -12.9,14.4c-3.133,6.067 -4.7,13.1 -4.7,21.1c0,7.867 1.567,14.933 4.7,21.2c3.134,6.267 7.434,11.167 12.9,14.7c5.467,3.533 11.8,5.3 19,5.3c7.067,0 13.3,-1.767 18.7,-5.3c5.4,-3.533 9.7,-8.433 12.9,-14.7c3.2,-6.267 4.8,-13.333 4.8,-21.2c0,-7.867 -1.6,-14.867 -4.8,-21c-3.2,-6.133 -7.5,-10.967 -12.9,-14.5c-5.4,-3.533 -11.633,-5.3 -18.7,-5.3Z" style="fill:#858585;fill-rule:nonzero;"/><path
+        d="M276.266,181.147c1.734,0 3.167,0.6 4.3,1.8c1.134,1.2 1.7,2.6 1.7,4.2l0,88.2c0,10.8 -2.133,19.9 -6.4,27.3c-4.266,7.4 -10,13 -17.2,16.8c-7.2,3.8 -15.4,5.7 -24.6,5.7c-5.733,-0 -11.033,-0.633 -15.9,-1.9c-4.866,-1.267 -8.833,-2.9 -11.9,-4.9c-1.6,-0.933 -2.8,-2.067 -3.6,-3.4c-0.8,-1.333 -0.866,-2.733 -0.2,-4.2c0.667,-1.867 1.7,-3.1 3.1,-3.7c1.4,-0.6 2.834,-0.567 4.3,0.1c2.267,1.2 5.534,2.667 9.8,4.4c4.267,1.733 9.134,2.6 14.6,2.6c7.334,-0 13.7,-1.533 19.1,-4.6c5.4,-3.067 9.567,-7.5 12.5,-13.3c2.934,-5.8 4.4,-12.7 4.4,-20.7l0,-14.4l1.4,4.8c-2,4.133 -4.833,7.8 -8.5,11c-3.666,3.2 -7.866,5.7 -12.6,7.5c-4.733,1.8 -9.766,2.7 -15.1,2.7c-8,-0 -14.633,-1.633 -19.9,-4.9c-5.266,-3.267 -9.166,-7.767 -11.7,-13.5c-2.533,-5.733 -3.8,-12.467 -3.8,-20.2l0,-61.4c0,-1.6 0.534,-3 1.6,-4.2c1.067,-1.2 2.534,-1.8 4.4,-1.8c1.734,0 3.167,0.6 4.3,1.8c1.134,1.2 1.7,2.6 1.7,4.2l0,59.6c0,9.333 2.034,16.533 6.1,21.6c4.067,5.067 10.7,7.6 19.9,7.6c5.734,0 11,-1.367 15.8,-4.1c4.8,-2.733 8.734,-6.333 11.8,-10.8c3.067,-4.467 4.6,-9.233 4.6,-14.3l0,-59.6c0,-1.6 0.567,-3 1.7,-4.2c1.134,-1.2 2.567,-1.8 4.3,-1.8Z"
+        style="fill:#858585;fill-rule:nonzero;"/><path
+        d="M349.666,182.147c8.267,0 15.334,2.167 21.2,6.5c5.867,4.333 9.867,10.767 12,19.3l-2.8,0.8l1.2,-3.6c1.734,-4 4.634,-7.767 8.7,-11.3c4.067,-3.533 8.7,-6.367 13.9,-8.5c5.2,-2.133 10.467,-3.2 15.8,-3.2c8.4,0 15.167,1.8 20.3,5.4c5.134,3.6 8.867,8.433 11.2,14.5c2.334,6.067 3.5,12.9 3.5,20.5l0,56.6c0,1.6 -0.566,3 -1.7,4.2c-1.133,1.2 -2.566,1.8 -4.3,1.8c-1.733,0 -3.166,-0.6 -4.3,-1.8c-1.133,-1.2 -1.7,-2.6 -1.7,-4.2l0,-56c0,-5.467 -0.866,-10.467 -2.6,-15c-1.733,-4.533 -4.466,-8.133 -8.2,-10.8c-3.733,-2.667 -8.733,-4 -15,-4c-5.466,0 -10.7,1.333 -15.7,4c-5,2.667 -9.066,6.267 -12.2,10.8c-3.133,4.533 -4.7,9.533 -4.7,15l0,56c0,1.6 -0.566,3 -1.7,4.2c-1.133,1.2 -2.566,1.8 -4.3,1.8c-1.733,0 -3.166,-0.6 -4.3,-1.8c-1.133,-1.2 -1.7,-2.6 -1.7,-4.2l0,-56.6c0,-5.467 -0.8,-10.4 -2.4,-14.8c-1.6,-4.4 -4.2,-7.9 -7.8,-10.5c-3.6,-2.6 -8.466,-3.9 -14.6,-3.9c-5.466,0 -10.633,1.3 -15.5,3.9c-4.866,2.6 -8.8,6.1 -11.8,10.5c-3,4.4 -4.5,9.333 -4.5,14.8l0,56.6c0,1.6 -0.6,3 -1.8,4.2c-1.2,1.2 -2.6,1.8 -4.2,1.8c-1.866,0 -3.333,-0.6 -4.4,-1.8c-1.066,-1.2 -1.6,-2.6 -1.6,-4.2l0,-89.6c0,-1.6 0.567,-3 1.7,-4.2c1.134,-1.2 2.567,-1.8 4.3,-1.8c1.734,0 3.167,0.6 4.3,1.8c1.134,1.2 1.7,2.6 1.7,4.2l0,18.6l-5.4,7.4c0.4,-4 1.7,-8 3.9,-12c2.2,-4 5.1,-7.6 8.7,-10.8c3.6,-3.2 7.7,-5.767 12.3,-7.7c4.6,-1.933 9.434,-2.9 14.5,-2.9Z"
+        style="fill-rule:nonzero;"/><path
+        d="M571.266,233.947c0,10.133 -2.2,19.233 -6.6,27.3c-4.4,8.067 -10.4,14.4 -18,19c-7.6,4.6 -16.266,6.9 -26,6.9c-9.466,-0 -18.033,-2.3 -25.7,-6.9c-7.666,-4.6 -13.733,-10.933 -18.2,-19c-4.466,-8.067 -6.7,-17.167 -6.7,-27.3c0,-10.267 2.234,-19.4 6.7,-27.4c4.467,-8 10.534,-14.333 18.2,-19c7.667,-4.667 16.234,-7 25.7,-7c9.734,0 18.4,2.333 26,7c7.6,4.667 13.6,11 18,19c4.4,8 6.6,17.133 6.6,27.4Zm-12,0c0,-8.133 -1.666,-15.367 -5,-21.7c-3.333,-6.333 -7.9,-11.333 -13.7,-15c-5.8,-3.667 -12.433,-5.5 -19.9,-5.5c-7.2,0 -13.733,1.833 -19.6,5.5c-5.866,3.667 -10.5,8.667 -13.9,15c-3.4,6.333 -5.1,13.567 -5.1,21.7c0,8.133 1.7,15.333 5.1,21.6c3.4,6.267 8.034,11.233 13.9,14.9c5.867,3.667 12.4,5.5 19.6,5.5c7.467,0 14.1,-1.833 19.9,-5.5c5.8,-3.667 10.367,-8.633 13.7,-14.9c3.334,-6.267 5,-13.467 5,-21.6Z"
+        style="fill-rule:nonzero;"/><path
+        d="M584.466,180.747c1.067,0 2.134,0.333 3.2,1c1.067,0.667 1.867,1.667 2.4,3l35,83.2l-2.6,1.6l35.8,-84.8c1.067,-2.667 3,-3.933 5.8,-3.8c1.334,0 2.6,0.433 3.8,1.3c1.2,0.867 1.8,2.1 1.8,3.7c0,0.8 -0.1,1.433 -0.3,1.9c-0.2,0.467 -0.366,0.967 -0.5,1.5l-39.2,92c-1.066,2.4 -2.866,3.667 -5.4,3.8c-1.2,0.133 -2.366,-0.167 -3.5,-0.9c-1.133,-0.733 -1.966,-1.7 -2.5,-2.9l-39.4,-92c-0.133,-0.267 -0.266,-0.667 -0.4,-1.2c-0.133,-0.533 -0.2,-1.067 -0.2,-1.6c0,-1.467 0.534,-2.8 1.6,-4c1.067,-1.2 2.6,-1.8 4.6,-1.8Z"
+        style="fill-rule:nonzero;"/><path
+        d="M728.866,287.147c-10.266,-0 -19.333,-2.2 -27.2,-6.6c-7.866,-4.4 -14,-10.533 -18.4,-18.4c-4.4,-7.867 -6.6,-17.067 -6.6,-27.6c0,-11.333 2.234,-21 6.7,-29c4.467,-8 10.267,-14.167 17.4,-18.5c7.134,-4.333 14.7,-6.5 22.7,-6.5c5.867,0 11.567,1.033 17.1,3.1c5.534,2.067 10.467,5.133 14.8,9.2c4.334,4.067 7.8,9.033 10.4,14.9c2.6,5.867 4.034,12.667 4.3,20.4c-0.133,1.6 -0.8,2.967 -2,4.1c-1.2,1.133 -2.6,1.7 -4.2,1.7l-80.2,0l-2.4,-10.8l78.8,0l-2.6,2.4l0,-4c-0.533,-6.267 -2.533,-11.6 -6,-16c-3.466,-4.4 -7.666,-7.733 -12.6,-10c-4.933,-2.267 -10.066,-3.4 -15.4,-3.4c-4,0 -8.1,0.8 -12.3,2.4c-4.2,1.6 -8,4.1 -11.4,7.5c-3.4,3.4 -6.166,7.733 -8.3,13c-2.133,5.267 -3.2,11.5 -3.2,18.7c0,7.867 1.6,15 4.8,21.4c3.2,6.4 7.834,11.467 13.9,15.2c6.067,3.733 13.3,5.6 21.7,5.6c4.667,0 8.867,-0.667 12.6,-2c3.734,-1.333 7,-3.1 9.8,-5.3c2.8,-2.2 5.134,-4.5 7,-6.9c1.467,-1.2 2.934,-1.8 4.4,-1.8c1.334,0 2.5,0.533 3.5,1.6c1,1.067 1.5,2.267 1.5,3.6c0,1.6 -0.666,3 -2,4.2c-4,4.8 -9.2,8.967 -15.6,12.5c-6.4,3.533 -13.4,5.3 -21,5.3Z"
+        style="fill-rule:nonzero;"/><path
+        d="M831.666,182.147c8.267,0 15.334,2.167 21.2,6.5c5.867,4.333 9.867,10.767 12,19.3l-2.8,0.8l1.2,-3.6c1.734,-4 4.634,-7.767 8.7,-11.3c4.067,-3.533 8.7,-6.367 13.9,-8.5c5.2,-2.133 10.467,-3.2 15.8,-3.2c8.4,0 15.167,1.8 20.3,5.4c5.134,3.6 8.867,8.433 11.2,14.5c2.334,6.067 3.5,12.9 3.5,20.5l0,56.6c0,1.6 -0.566,3 -1.7,4.2c-1.133,1.2 -2.566,1.8 -4.3,1.8c-1.733,0 -3.166,-0.6 -4.3,-1.8c-1.133,-1.2 -1.7,-2.6 -1.7,-4.2l0,-56c0,-5.467 -0.866,-10.467 -2.6,-15c-1.733,-4.533 -4.466,-8.133 -8.2,-10.8c-3.733,-2.667 -8.733,-4 -15,-4c-5.466,0 -10.7,1.333 -15.7,4c-5,2.667 -9.066,6.267 -12.2,10.8c-3.133,4.533 -4.7,9.533 -4.7,15l0,56c0,1.6 -0.566,3 -1.7,4.2c-1.133,1.2 -2.566,1.8 -4.3,1.8c-1.733,0 -3.166,-0.6 -4.3,-1.8c-1.133,-1.2 -1.7,-2.6 -1.7,-4.2l0,-56.6c0,-5.467 -0.8,-10.4 -2.4,-14.8c-1.6,-4.4 -4.2,-7.9 -7.8,-10.5c-3.6,-2.6 -8.466,-3.9 -14.6,-3.9c-5.466,0 -10.633,1.3 -15.5,3.9c-4.866,2.6 -8.8,6.1 -11.8,10.5c-3,4.4 -4.5,9.333 -4.5,14.8l0,56.6c0,1.6 -0.6,3 -1.8,4.2c-1.2,1.2 -2.6,1.8 -4.2,1.8c-1.866,0 -3.333,-0.6 -4.4,-1.8c-1.066,-1.2 -1.6,-2.6 -1.6,-4.2l0,-89.6c0,-1.6 0.567,-3 1.7,-4.2c1.134,-1.2 2.567,-1.8 4.3,-1.8c1.734,0 3.167,0.6 4.3,1.8c1.134,1.2 1.7,2.6 1.7,4.2l0,18.6l-5.4,7.4c0.4,-4 1.7,-8 3.9,-12c2.2,-4 5.1,-7.6 8.7,-10.8c3.6,-3.2 7.7,-5.767 12.3,-7.7c4.6,-1.933 9.434,-2.9 14.5,-2.9Z"
+        style="fill-rule:nonzero;"/><path
+        d="M1004.27,287.147c-10.266,-0 -19.333,-2.2 -27.2,-6.6c-7.866,-4.4 -14,-10.533 -18.4,-18.4c-4.4,-7.867 -6.6,-17.067 -6.6,-27.6c0,-11.333 2.234,-21 6.7,-29c4.467,-8 10.267,-14.167 17.4,-18.5c7.134,-4.333 14.7,-6.5 22.7,-6.5c5.867,0 11.567,1.033 17.1,3.1c5.534,2.067 10.467,5.133 14.8,9.2c4.334,4.067 7.8,9.033 10.4,14.9c2.6,5.867 4.034,12.667 4.3,20.4c-0.133,1.6 -0.8,2.967 -2,4.1c-1.2,1.133 -2.6,1.7 -4.2,1.7l-80.2,0l-2.4,-10.8l78.8,0l-2.6,2.4l0,-4c-0.533,-6.267 -2.533,-11.6 -6,-16c-3.466,-4.4 -7.666,-7.733 -12.6,-10c-4.933,-2.267 -10.066,-3.4 -15.4,-3.4c-4,0 -8.1,0.8 -12.3,2.4c-4.2,1.6 -8,4.1 -11.4,7.5c-3.4,3.4 -6.166,7.733 -8.3,13c-2.133,5.267 -3.2,11.5 -3.2,18.7c0,7.867 1.6,15 4.8,21.4c3.2,6.4 7.834,11.467 13.9,15.2c6.067,3.733 13.3,5.6 21.7,5.6c4.667,0 8.867,-0.667 12.6,-2c3.734,-1.333 7,-3.1 9.8,-5.3c2.8,-2.2 5.134,-4.5 7,-6.9c1.467,-1.2 2.934,-1.8 4.4,-1.8c1.334,0 2.5,0.533 3.5,1.6c1,1.067 1.5,2.267 1.5,3.6c0,1.6 -0.666,3 -2,4.2c-4,4.8 -9.2,8.967 -15.6,12.5c-6.4,3.533 -13.4,5.3 -21,5.3Z"
+        style="fill-rule:nonzero;"/><path
+        d="M1109.27,180.547c8.8,0 15.834,1.767 21.1,5.3c5.267,3.533 9.067,8.333 11.4,14.4c2.334,6.067 3.5,12.767 3.5,20.1l0,58.8c0,1.6 -0.6,3 -1.8,4.2c-1.2,1.2 -2.6,1.8 -4.2,1.8c-1.866,0 -3.333,-0.6 -4.4,-1.8c-1.066,-1.2 -1.6,-2.6 -1.6,-4.2l0,-58.2c0,-5.333 -0.866,-10.2 -2.6,-14.6c-1.733,-4.4 -4.566,-7.933 -8.5,-10.6c-3.933,-2.667 -9.1,-4 -15.5,-4c-5.733,0 -11.166,1.333 -16.3,4c-5.133,2.667 -9.3,6.2 -12.5,10.6c-3.2,4.4 -4.8,9.267 -4.8,14.6l0,58.2c0,1.6 -0.6,3 -1.8,4.2c-1.2,1.2 -2.6,1.8 -4.2,1.8c-1.866,0 -3.333,-0.6 -4.4,-1.8c-1.066,-1.2 -1.6,-2.6 -1.6,-4.2l0,-89.6c0,-1.6 0.567,-3 1.7,-4.2c1.134,-1.2 2.567,-1.8 4.3,-1.8c1.734,0 3.167,0.6 4.3,1.8c1.134,1.2 1.7,2.6 1.7,4.2l0,16.8l-4.6,7.2c0.267,-4.267 1.634,-8.367 4.1,-12.3c2.467,-3.933 5.634,-7.467 9.5,-10.6c3.867,-3.133 8.167,-5.6 12.9,-7.4c4.734,-1.8 9.5,-2.7 14.3,-2.7Z"
+        style="fill-rule:nonzero;"/><path
+        d="M1160.07,183.347l46.6,0c1.6,0 2.934,0.567 4,1.7c1.067,1.133 1.6,2.5 1.6,4.1c0,1.467 -0.533,2.733 -1.6,3.8c-1.066,1.067 -2.4,1.6 -4,1.6l-46.6,0c-1.6,0 -2.966,-0.567 -4.1,-1.7c-1.133,-1.133 -1.7,-2.433 -1.7,-3.9c0,-1.6 0.567,-2.933 1.7,-4c1.134,-1.067 2.5,-1.6 4.1,-1.6Zm20.6,-28.2c1.734,0 3.167,0.6 4.3,1.8c1.134,1.2 1.7,2.6 1.7,4.2l0,100.4c0,3.733 0.5,6.533 1.5,8.4c1,1.867 2.3,3.1 3.9,3.7c1.6,0.6 3.2,0.9 4.8,0.9c1.067,0 2.034,-0.167 2.9,-0.5c0.867,-0.333 1.834,-0.5 2.9,-0.5c1.2,0 2.234,0.467 3.1,1.4c0.867,0.933 1.3,2.133 1.3,3.6c0,1.867 -1.066,3.433 -3.2,4.7c-2.133,1.267 -4.666,1.9 -7.6,1.9c-1.2,0 -3,-0.1 -5.4,-0.3c-2.4,-0.2 -4.866,-0.933 -7.4,-2.2c-2.533,-1.267 -4.633,-3.5 -6.3,-6.7c-1.666,-3.2 -2.5,-7.8 -2.5,-13.8l0,-101c0,-1.6 0.567,-3 1.7,-4.2c1.134,-1.2 2.567,-1.8 4.3,-1.8Z"
+        style="fill-rule:nonzero;"/><path
+        d="M1218.67,268.947c-1.066,-1.6 -1.566,-3.167 -1.5,-4.7c0.067,-1.533 0.9,-2.767 2.5,-3.7c1.067,-0.933 2.3,-1.333 3.7,-1.2c1.4,0.133 2.7,0.8 3.9,2c3.6,4.667 7.967,8.433 13.1,11.3c5.134,2.867 11.234,4.3 18.3,4.3c3.734,0 7.434,-0.633 11.1,-1.9c3.667,-1.267 6.7,-3.2 9.1,-5.8c2.4,-2.6 3.6,-5.9 3.6,-9.9c0,-4.133 -1.2,-7.467 -3.6,-10c-2.4,-2.533 -5.5,-4.533 -9.3,-6c-3.8,-1.467 -7.833,-2.733 -12.1,-3.8c-4.533,-1.2 -8.933,-2.567 -13.2,-4.1c-4.266,-1.533 -8.033,-3.467 -11.3,-5.8c-3.266,-2.333 -5.9,-5.167 -7.9,-8.5c-2,-3.333 -3,-7.4 -3,-12.2c0,-5.6 1.534,-10.533 4.6,-14.8c3.067,-4.267 7.234,-7.6 12.5,-10c5.267,-2.4 11.234,-3.6 17.9,-3.6c3.334,0 6.934,0.433 10.8,1.3c3.867,0.867 7.667,2.3 11.4,4.3c3.734,2 7.067,4.733 10,8.2c1.2,1.2 1.8,2.567 1.8,4.1c0,1.533 -0.666,2.9 -2,4.1c-1.066,0.8 -2.3,1.2 -3.7,1.2c-1.4,0 -2.566,-0.533 -3.5,-1.6c-3.2,-3.6 -7,-6.333 -11.4,-8.2c-4.4,-1.867 -9.133,-2.8 -14.2,-2.8c-3.866,0 -7.5,0.633 -10.9,1.9c-3.4,1.267 -6.233,3.167 -8.5,5.7c-2.266,2.533 -3.4,5.867 -3.4,10c0.267,3.867 1.634,7 4.1,9.4c2.467,2.4 5.734,4.4 9.8,6c4.067,1.6 8.634,3.067 13.7,4.4c4.267,1.067 8.367,2.333 12.3,3.8c3.934,1.467 7.4,3.3 10.4,5.5c3,2.2 5.4,4.967 7.2,8.3c1.8,3.333 2.7,7.533 2.7,12.6c0,5.867 -1.633,10.933 -4.9,15.2c-3.266,4.267 -7.633,7.533 -13.1,9.8c-5.466,2.267 -11.533,3.4 -18.2,3.4c-7.2,-0 -14.166,-1.4 -20.9,-4.2c-6.733,-2.8 -12.7,-7.467 -17.9,-14Z"
+        style="fill-rule:nonzero;"/></g>
+    <path d="M1583.5,59.816c-79.962,-0 -64.577,273.992 -144.539,273.992c-87.957,-0 -36.427,-270.482 -212.771,-270.482l-1217.22,0"
+          style="fill:none;stroke:url(#_Linear1);stroke-width:25.22px;"/>
+    <path d="M1583.5,259.513c-23.159,-0 -81.953,0.896 -103.049,-21.742c-54.933,-58.948 2.032,-186.995 -177.798,-186.995l-1293.68,0"
+          style="fill:none;stroke:url(#_Linear2);stroke-width:26.6px;"/>
+    <defs><linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-2776.95,18.3963,-9.53285,-1439,2422,-41.2789)"><stop offset="0" style="stop-color:#fff;stop-opacity:0.06"/><stop
+            offset="0.16" style="stop-color:#888aff;stop-opacity:0.29"/><stop offset="0.35"
+                                                                              style="stop-color:#2429ff;stop-opacity:0.49"/><stop
+            offset="1" style="stop-color:#0006ff;stop-opacity:0.56"/></linearGradient>
+        <linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
+                        gradientTransform="matrix(1965.08,24.0183,-14.4998,1186.31,-475.035,253.425)"><stop offset="0" style="stop-color:#fff;stop-opacity:0.06"/>
+            <stop offset="0.15" style="stop-color:#ff84a8;stop-opacity:0.33"/>
+            <stop offset="0.33" style="stop-color:#ff2263;stop-opacity:0.55"/>
+            <stop offset="1" style="stop-color:#ff004b;stop-opacity:0.62"/></linearGradient></defs></svg>
\ No newline at end of file
diff --git a/docs/source/reference/index.rst b/docs/source/reference/index.rst
index 00485cb..485f62e 100644
--- a/docs/source/reference/index.rst
+++ b/docs/source/reference/index.rst
@@ -9,9 +9,8 @@
     :template: module.rst
 
     pymovements.base
-    pymovements.checks
     pymovements.events
-    pymovements.paths
     pymovements.plot
     pymovements.synthetic
-    pymovements.transforms
\ No newline at end of file
+    pymovements.transforms
+    pymovements.utils
diff --git a/pylintrc b/pylintrc
index 132109e..59e6a65 100644
--- a/pylintrc
+++ b/pylintrc
@@ -100,6 +100,7 @@ disable=
     too-many-arguments,
     too-many-locals,
     redefined-variable-type,
+    consider-using-namedtuple-or-dataclass,
 
 [REPORTS]
 
@@ -508,7 +509,7 @@ preferred-modules=
 
 # Exceptions that will emit a warning when being caught. Defaults to
 # "Exception"
-overgeneral-exceptions=Exception
+overgeneral-exceptions=builtins.Exception
 
 
 [TYPING]
diff --git a/setup.cfg b/setup.cfg
index 5dd7d31..4fe307f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -13,13 +13,21 @@ project_urls =
     Documentation = https://pymovements.readthedocs.io/en/latest
 classifiers =
     Development Status :: 3 - Alpha
+    Intended Audience :: Science/Research
+    Intended Audience :: Developers
     License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
     Operating System :: OS Independent
+    Operating System :: POSIX :: Linux
+    Operating System :: Microsoft :: Windows
+    Operating System :: MacOS :: MacOS X
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
     Programming Language :: Python :: 3.11
+    Topic :: Software Development
+    Topic :: Scientific/Engineering
+    Typing :: Typed
 
 [options]
 package_dir =
@@ -30,12 +38,14 @@ install_requires =
     numpy>=1.10.0
     scipy>=1.5.4
     matplotlib>=3.0.0
+    tqdm>=4.0.0
 
 [options.extras_require]
 test =
      pytest>=6.0.0
      pytest-cov>=3.0.0
      pybtex
+     types-tqdm
 docs =
      sphinx-copybutton>=0.4.0
      sphinx-rtd-theme>=1.0.0
@@ -44,6 +54,7 @@ docs =
      nbsphinx>=0.8.8
      ipykernel>=6.13.0
      pybtex
+     pandoc
 
 [options.packages.find]
 where = src
diff --git a/src/pymovements/base.py b/src/pymovements/base.py
index b602fbc..5f2ec27 100644
--- a/src/pymovements/base.py
+++ b/src/pymovements/base.py
@@ -4,9 +4,10 @@ This module holds the base classes Screen and Experiment.
 from __future__ import annotations
 
 import numpy as np
-from pymovements import checks
+
 from pymovements.transforms import pix2deg
-from pymovements.utils import auto_str
+from pymovements.utils import checks
+from pymovements.utils.decorators import auto_str
 
 
 @auto_str
diff --git a/src/pymovements/plot.py b/src/pymovements/plot.py
index 564b852..836963b 100644
--- a/src/pymovements/plot.py
+++ b/src/pymovements/plot.py
@@ -10,7 +10,6 @@ from matplotlib import colors
 from matplotlib.collections import LineCollection
 
 
-# pylint: disable=consider-using-namedtuple-or-dataclass
 default_segmentdata = {
     'red':   [
         [0.0,  0.0, 0.0],
diff --git a/src/pymovements/synthetic/__init__.py b/src/pymovements/synthetic/__init__.py
index 3ac39cc..70213d0 100644
--- a/src/pymovements/synthetic/__init__.py
+++ b/src/pymovements/synthetic/__init__.py
@@ -1,6 +1,4 @@
 """
 This module holds functionality for generating synthetic eye gaze data.
 """
-
-
 from pymovements.synthetic.step_function import step_function  # noqa: F401
diff --git a/src/pymovements/synthetic/step_function.py b/src/pymovements/synthetic/step_function.py
index 3ad5aae..a9b62e8 100644
--- a/src/pymovements/synthetic/step_function.py
+++ b/src/pymovements/synthetic/step_function.py
@@ -1,18 +1,18 @@
 """
 This module holds the synthetic eye gaze step function.
 """
-
-
 from __future__ import annotations
 
+from collections.abc import Sized
+
 import numpy as np
 
 
 def step_function(
     length: int,
     steps: list[int],
-    values: list[float],
-    start_value: float = 0,
+    values: list[float | tuple[float, ...]],
+    start_value: float | tuple[float, ...] = 0,
 ) -> np.ndarray:
     """
     Create a synthetic eye gaze by using a simple step function.
@@ -49,14 +49,48 @@ def step_function(
         ... )
         array([0., 0., 1., 1., 1., 2., 2., 2., 2., 3.])
     """
+    # Check that steps and values have equal length.
     if len(steps) != len(values):
-        raise ValueError('length of steps not equal to length of values'
-                         f' ({len(steps)} != {len(values)})')
+        raise ValueError(
+            'length of steps not equal to length of values'
+            f' ({len(steps)} != {len(values)})',
+        )
 
+    # Check that steps are sorted in ascending order.
     if sorted(steps) != steps:
-        raise ValueError('steps need to be sorted in ascending order.')
+        raise ValueError('steps must be sorted in ascending order.')
+
+    # Infer number of channels from values.
+    if isinstance(values[0], (int, float)):
+        n_channels = 1
+    else:
+        n_channels = len(values[0])
+
+    # Check that all values have equal number of channels.
+    if n_channels == 1:
+        if any(not isinstance(value, (int, float)) for value in values):
+            raise ValueError('all values must have equal number of channels.')
+    elif any(not isinstance(value, Sized) or len(value) != n_channels for value in values):
+        raise ValueError('all values must have equal number of channels.')
+
+    # Make sure start value corresponds to number of channels.
+    if n_channels > 1:
+
+        # If start value is a scalar, create tuple with length of number of channels.
+        if isinstance(start_value, (int, float)):
+            start_value = tuple(start_value for _ in range(n_channels))
+
+        # Raise error if length of start value doesn't match n_channels.
+        elif len(start_value) != n_channels:
+            raise ValueError(
+                'start_value must be scalar or must have same number of channels as values.',
+            )
 
-    arr = np.ones(length) * start_value
+    # Initialize output array with start value.
+    if n_channels == 1:
+        arr = np.ones(length) * start_value
+    else:
+        arr = np.tile(start_value, (length, 1))
 
     # Iterate through all steps except the last.
     for begin, end, value in zip(steps[:-1], steps[1:], values[:-1]):
diff --git a/src/pymovements/transforms.py b/src/pymovements/transforms.py
index 7a69a79..cf6e281 100644
--- a/src/pymovements/transforms.py
+++ b/src/pymovements/transforms.py
@@ -2,12 +2,13 @@
 Transforms module.
 """
 from __future__ import annotations
+
 from typing import Any
 
 import numpy as np
 import scipy
 
-from . import checks
+from pymovements.utils import checks
 
 
 def pix2deg(
@@ -214,9 +215,9 @@ def pos2vel(
     return v
 
 
-def vnorm(arr: np.ndarray, axis: int | None = None) -> np.ndarray | Any:
+def norm(arr: np.ndarray, axis: int | None = None) -> np.ndarray | Any:
     """
-    Takes the velocity norm sqrt(x^2 + y^2).
+    Takes the norm sqrt(x^2 + y^2).
 
     Parameters
     ----------
diff --git a/src/pymovements/utils/__init__.py b/src/pymovements/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pymovements/utils/archives.py b/src/pymovements/utils/archives.py
new file mode 100644
index 0000000..58baadf
--- /dev/null
+++ b/src/pymovements/utils/archives.py
@@ -0,0 +1,232 @@
+"""
+Utils module for extracting archives and decompressing files.
+"""
+from __future__ import annotations
+
+import bz2
+import gzip
+import lzma
+import tarfile
+import zipfile
+from collections.abc import Callable
+from pathlib import Path
+from typing import IO
+
+
+def extract_archive(
+    source_path: Path,
+    destination_path: Path | None = None,
+    remove_finished: bool = False,
+) -> Path:
+    """Extract an archive.
+    The archive type and a possible compression is automatically detected from the file name.
+    If the file is compressed but not an archive the call is dispatched to :func:`_decompress`.
+
+    Parameters
+    ----------
+    source_path : Path
+        Path to the file to be extracted.
+    destination_path : Path, optional
+        Path to the directory the file will be extracted to. If omitted, the directory of the file
+        is used.
+    remove_finished : bool
+        If ``True``, remove the file after the extraction.
+
+    Returns
+    -------
+    Path :
+        Path to the directory the file was extracted to.
+    """
+    archive_type, compression_type = _detect_file_type(source_path)
+
+    if not archive_type:
+        return _decompress(
+            source_path=source_path,
+            destination_path=destination_path,
+            remove_finished=remove_finished,
+        )
+
+    # We don't need to check for a missing key, since this was already done in__detect_file_type().
+    extractor = _ARCHIVE_EXTRACTORS[archive_type]
+
+    if destination_path is None:
+        destination_path = source_path.parent
+
+    # Extract file and remove archive if desired.
+    extractor(source_path, destination_path, compression_type)
+    if remove_finished:
+        source_path.unlink()
+
+    return destination_path
+
+
+def _extract_tar(
+        source_path: Path,
+        destination_path: Path,
+        compression: str | None,
+) -> None:
+    """Extract a tar archive.
+
+    Parameters
+    ----------
+    source_path: Path
+        Path to the file to be extracted.
+    destination_path : Path
+        Path to the directory the file will be extracted to.
+    compression : str, optional
+        Compression filename suffix.
+    """
+    with tarfile.open(source_path, f"r:{compression[1:]}" if compression else "r") as archive:
+        archive.extractall(destination_path)
+
+
+_ZIP_COMPRESSION_MAP: dict[str, int] = {
+    ".bz2": zipfile.ZIP_BZIP2,
+    ".xz": zipfile.ZIP_LZMA,
+}
+
+
+def _extract_zip(
+    source_path: Path,
+    destination_path: Path,
+    compression: str | None,
+) -> None:
+    """Extract a zip archive.
+
+    Parameters
+    ----------
+    source_path: Path
+        Path to the file to be extracted.
+    destination_path : Path
+        Path to the directory the file will be extracted to.
+    compression : str, optional
+        Compression filename suffix.
+    """
+    compression_id = _ZIP_COMPRESSION_MAP[compression] if compression else zipfile.ZIP_STORED
+    with zipfile.ZipFile(source_path, "r", compression=compression_id) as archive:
+        archive.extractall(destination_path)
+
+
+_ARCHIVE_EXTRACTORS: dict[str, Callable[[Path, Path, str | None], None]] = {
+    ".tar": _extract_tar,
+    ".zip": _extract_zip,
+}
+
+_ARCHIVE_TYPE_ALIASES: dict[str, tuple[str, str]] = {
+    ".tbz": (".tar", ".bz2"),
+    ".tbz2": (".tar", ".bz2"),
+    ".tgz": (".tar", ".gz"),
+}
+
+_COMPRESSED_FILE_OPENERS: dict[str, Callable[..., IO]] = {
+    ".bz2": bz2.open,
+    ".gz": gzip.open,
+    ".xz": lzma.open,
+}
+
+
+def _detect_file_type(filepath: Path) -> tuple[str | None, str | None]:
+    """Detect the archive type and/or compression of a file.
+
+    Parameters
+    ----------
+    filepath : Path
+        The path of the file.
+
+    Returns
+    -------
+    tuple[str, str] :
+        Tuple of archive type, and compression type.
+
+    Raises
+    ------
+    RuntimeError
+        If the file has no suffix or the suffix is not supported.
+    """
+    suffixes = filepath.suffixes
+
+    if not suffixes:
+        raise RuntimeError(
+            f"File '{filepath}' has no suffixes that could be used to detect the archive type or"
+            " compression.",
+        )
+
+    # Get last suffix only.
+    suffix = suffixes[-1]
+
+    # Check if suffix is a known alias.
+    if suffix in _ARCHIVE_TYPE_ALIASES:
+        return _ARCHIVE_TYPE_ALIASES[suffix]
+
+    # Check if suffix refers to an archive type.
+    if suffix in _ARCHIVE_EXTRACTORS:
+        return suffix, None
+
+    # Check if the suffix refers to a compression type.
+    if suffix in _COMPRESSED_FILE_OPENERS:
+        # Check if there are more than one suffix.
+        if len(suffixes) > 1:
+            suffix2 = suffixes[-2]
+
+            # Check if the second last suffix refers to an archive type.
+            if suffix2 in _ARCHIVE_EXTRACTORS:
+                return suffix2, suffix
+
+        # We detected a single compressed file not an archive.
+        return None, suffix
+
+    # Raise error as we didn't find a valid suffix.
+    valid_suffixes = sorted(
+        set(_ARCHIVE_TYPE_ALIASES) | set(_ARCHIVE_EXTRACTORS) | set(_COMPRESSED_FILE_OPENERS),
+    )
+    raise RuntimeError(
+        f"Unsupported compression or archive type: '{suffix}'.\n"
+        f"Supported suffixes are: '{valid_suffixes}'.",
+    )
+
+
+def _decompress(
+    source_path: Path,
+    destination_path: Path | None = None,
+    remove_finished: bool = False,
+) -> Path:
+    r"""Decompress a file.
+
+    The compression is automatically detected from the file name.
+
+    Parameters
+    ----------
+    source_path : Path
+        Path to the file to be decompressed.
+    destination_path : Path
+        Path to the decompressed file.
+    remove_finished : bool
+        If ``True``, remove the source file after decompression.
+
+    Returns
+    -------
+    Path : Path to the decompressed file.
+
+    Raises
+    ------
+    RuntimeError
+        If the file has no suffix or the suffix is not supported for decompression.
+    """
+    _, compression = _detect_file_type(source_path)
+    if not compression:
+        raise RuntimeError(f"Couldn't detect a compression from suffix {source_path.suffix}.")
+
+    if destination_path is None:
+        destination_path = source_path.parent / source_path.stem
+
+    # We don't need to check for a missing key, since this was already done in _detect_file_type().
+    compressed_file_opener = _COMPRESSED_FILE_OPENERS[compression]
+
+    # Decompress by reading from compressed file and writing to destination.
+    with compressed_file_opener(source_path, "rb") as rfh, open(destination_path, "wb") as wfh:
+        wfh.write(rfh.read())
+
+    if remove_finished:
+        source_path.unlink()
+
+    return destination_path
diff --git a/src/pymovements/checks.py b/src/pymovements/utils/checks.py
similarity index 100%
rename from src/pymovements/checks.py
rename to src/pymovements/utils/checks.py
diff --git a/src/pymovements/utils.py b/src/pymovements/utils/decorators.py
similarity index 100%
rename from src/pymovements/utils.py
rename to src/pymovements/utils/decorators.py
diff --git a/src/pymovements/utils/downloads.py b/src/pymovements/utils/downloads.py
new file mode 100644
index 0000000..91433b1
--- /dev/null
+++ b/src/pymovements/utils/downloads.py
@@ -0,0 +1,261 @@
+"""
+Utils module for downloading files.
+"""
+from __future__ import annotations
+
+import hashlib
+import urllib.request
+from pathlib import Path
+
+from tqdm.auto import tqdm
+
+from pymovements.utils.archives import extract_archive
+
+USER_AGENT: str = 'aeye-lab/pymovements'
+
+
+def download_and_extract_archive(
+    url: str,
+    download_dirpath: Path,
+    download_filename: str,
+    extract_dirpath: Path | None = None,
+    md5: str | None = None,
+    remove_finished: bool = False,
+) -> None:
+    """Download and extract archive file.
+
+    Parameters
+    ----------
+    url : str
+        URL of archive file to be downloaded.
+    download_dirpath : Path
+        Path to directory where file will be saved to.
+    download_filename : str, optional
+        Target filename of saved file.
+    extract_dirpath : Path, optional
+        Path to directory where archive files will be extracted to.
+    md5 : str, optional
+        MD5 checksum of downloaded file. If None, do not check.
+    remove_finished : bool
+        Remove downloaded file after successful extraction or decompression.
+
+    Raises
+    ------
+    RuntimeError
+        If the downloaded file has no suffix or suffix is not supported, or in case of a
+        specified MD5 checksum which doesn't match the checksum of the downloaded file.
+    """
+    archive_path = download_file(
+        url=url,
+        dirpath=download_dirpath,
+        filename=download_filename,
+        md5=md5,
+    )
+
+    if extract_dirpath is None:
+        extract_dirpath = download_dirpath
+
+    print(f"Extracting {archive_path.name} to {extract_dirpath}")
+    extract_archive(archive_path, extract_dirpath, remove_finished)
+
+
+def download_file(
+    url: str,
+    dirpath: Path,
+    filename: str,
+    md5: str | None = None,
+    max_redirect_hops: int = 3,
+) -> Path:
+    """Download a file from a URL and place it in root.
+
+    Parameters
+    ----------
+    url : str
+        URL of file to be downloaded.
+    dirpath : Path
+        Path to directory where file will be saved to.
+    filename : str
+        Target filename of saved file.
+    md5 : str, optional
+        MD5 checksum of downloaded file. If None, do not check.
+    max_redirect_hops : int, optional
+        Maximum number of redirect hops allowed.
+
+    Returns
+    -------
+    pathlib.Path :
+        Filepath to downloaded file.
+
+    Raises
+    ------
+    OSError
+        If the download process failed.
+    RuntimeError
+        If the MD5 checksum of the downloaded file did not match the expected checksum.
+    """
+    dirpath = dirpath.expanduser()
+    dirpath.mkdir(parents=True, exist_ok=True)
+    filepath = dirpath / filename
+
+    # check if file is already present locally
+    if _check_integrity(filepath, md5):
+        print("Using already downloaded and verified file:", filepath)
+        return filepath
+    print(f"Downloading {url} to {filepath}")
+
+    # expand redirect chain if needed
+    url = _get_redirected_url(url=url, max_hops=max_redirect_hops)
+
+    # download the file
+    try:
+        _download_url(url=url, destination=filepath)
+
+    except OSError as e:
+        if url[:5] == "https":
+            url = url.replace("https:", "http:")
+            print("Download failed. Trying https -> http instead.")
+            print(f"Downloading {url} to {filepath}")
+            _download_url(url=url, destination=filepath)
+        else:
+            raise e
+
+    # check integrity of downloaded file
+    if not _check_integrity(filepath=filepath, md5=md5):
+        raise RuntimeError(f"File {'filepath'} not found or download corrupted.")
+
+    return filepath
+
+
+def _get_redirected_url(
+    url: str,
+    max_hops: int = 3,
+) -> str:
+    """Get redirected URL.
+
+    Parameters
+    ----------
+    url : str
+        Initial URL to be requested for redirection.
+    max_hops : int
+        Maximum number of redirection hops.
+
+    Returns
+    -------
+    str : Final URL after all redirections.
+
+    Raises
+    ------
+    RuntimeError
+        If number of redirects exceed `max_hops`.
+    """
+    initial_url = url
+    headers = {"Method": "HEAD", "User-Agent": USER_AGENT}
+
+    for _ in range(max_hops + 1):
+        with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as response:
+            if response.url == url or response.url is None:
+                return url
+            url = response.url
+
+    raise RuntimeError(
+        f"Request to {initial_url} exceeded {max_hops} redirects."
+        f" The last redirect points to {url}.",
+    )
+
+
+class _DownloadProgressBar(tqdm):
+    """Progress bar for downloads.
+
+    Provides `update_to(n)` which uses `tqdm.update(delta_n)`.
+
+    Reference: https://github.com/tqdm/tqdm#hooks-and-callbacks
+    """
+    def __init__(self, **kwargs):
+        super().__init__(unit='B', unit_scale=True, unit_divisor=1024, miniters=1, **kwargs)
+
+    def update_to(self, b=1, bsize=1, tsize=None):
+        """
+        b  : int, optional
+            Number of blocks transferred so far [default: 1].
+        bsize  : int, optional
+            Size of each block (in tqdm units) [default: 1].
+        tsize  : int, optional
+            Total size (in tqdm units). If None it remains unchanged [default: None].
+        """
+        if tsize is not None:
+            self.total = tsize
+        return self.update(b * bsize - self.n)  # also sets self.n = b * bsize
+
+
+def _download_url(
+    url: str,
+    destination: Path,
+):
+    """Download file from URL and save to destination.
+
+    Parameters
+    ----------
+    url : str
+        URL of file to be downloaded.
+    destination : Path
+        Destination path of downloaded file.
+    """
+    with _DownloadProgressBar(desc=destination.name) as t:
+        urllib.request.urlretrieve(url=url, filename=destination, reporthook=t.update_to)
+        t.total = t.n
+
+
+def _check_integrity(
+    filepath: Path,
+    md5: str | None = None,
+) -> bool:
+    """Check file integrity by MD5 checksum.
+
+    Parameters
+    ----------
+    filepath : Path
+        Path to file.
+    md5 : str, optional
+        Expected MD5 checksum of file. If None, do not check.
+
+    Returns
+    -------
+    bool : True if file checksum matches passed `md5` or if passed `md5` is None. False if file
+        checksum does not match passed `md5` or `filepath` doesn't exist.
+    """
+    if not filepath.is_file():
+        return False
+    if md5 is None:
+        return True
+
+    # Calculate checksum and check for match.
+    file_md5 = _calculate_md5(filepath)
+    return file_md5 == md5
+
+
+def _calculate_md5(
+    filepath: Path,
+    chunk_size: int = 1024 * 1024,
+) -> str:
+    """Calculate MD5 checksum.
+
+    Parameters
+    ----------
+    filepath : Path
+        Path to file.
+    chunk_size : int
+        Byte size of processed chunks.
+
+    Returns
+    -------
+    str : Calculated MD5 checksum.
+    """
+    # Setting the `usedforsecurity` flag does not change anything about the functionality, but
+    # indicates that we are not using the MD5 checksum for cryptography.
+    # This enables its usage in restricted environments like FIPS without raising an error.
+    file_md5 = hashlib.new('md5', usedforsecurity=False)  # type: ignore[call-arg]
+
+    with open(filepath, "rb") as f:
+        for chunk in iter(lambda: f.read(chunk_size), b""):
+            file_md5.update(chunk)
+    return file_md5.hexdigest()
diff --git a/src/pymovements/paths.py b/src/pymovements/utils/paths.py
similarity index 93%
rename from src/pymovements/paths.py
rename to src/pymovements/utils/paths.py
index f7c6e82..d0e8eb9 100644
--- a/src/pymovements/paths.py
+++ b/src/pymovements/utils/paths.py
@@ -45,7 +45,7 @@ def get_filepaths(
     filepaths = []
     for childpath in rootpath.iterdir():
         if childpath.is_dir():
-            filepaths.extend(get_filepaths(childpath, extension))
+            filepaths.extend(get_filepaths(rootpath=childpath, extension=extension, regex=regex))
         else:
             # if extension specified and not matching, continue to next
             if extension and childpath.suffix != extension:
diff --git a/tests/test_checks.py b/tests/utils/test_checks.py
similarity index 100%
rename from tests/test_checks.py
rename to tests/utils/test_checks.py
diff --git a/tox.ini b/tox.ini
index 77159d4..0e30c8e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -79,7 +79,6 @@ commands = pre-commit run --all-files --show-diff-on-failure
 
 
 [testenv:mypy]
-basepython = python3.8
 changedir = {toxinidir}
 deps =
     mypy
@@ -87,7 +86,6 @@ deps =
     types-all
 commands =
     mypy {toxinidir}/src
-    mypy {toxinidir}/tests
 
 
 [flake8]
@@ -97,8 +95,10 @@ max-line-length = 100
 
 
 [pytest]
-testpaths = tests
-addopts = -ra -l --doctest-modules
+testpaths =
+    tests
+    src
+addopts = -ra -l -v --doctest-modules
 filterwarnings =
     ignore::DeprecationWarning:pkg_resources.*:
 


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c3f49d2..72c88b6 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -7,11 +7,21 @@ on:
 
 jobs:
   test:
-    name: test ${{matrix.tox_env}}
-    runs-on: ubuntu-latest
+    name: test ${{ matrix.tox_env }} (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false
       matrix:
+        os:
+          - macos-latest
+          - ubuntu-latest
+          - windows-latest
+        tox_env:
+          - py37
+          - py38
+          - py39
+          - py310
+          - py311
         include:
           - tox_env: py37
             python: "3.7"
@@ -24,17 +34,17 @@ jobs:
           - tox_env: py311
             python: "3.11"
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
       - name: Install base python for tox
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: "3.9"
       - name: Install tox
         run: python -m pip install tox
       - name: Install python for test
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python }}
       - name: Setup test environment
@@ -44,20 +54,23 @@ jobs:
 
 
   check:
-    name: check ${{ matrix.tox_env }}
-    runs-on: ubuntu-latest
+    name: check ${{ matrix.tox_env }} (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false
       matrix:
+        os:
+          - ubuntu-latest
         tox_env:
           - flake8
           - pylint
+          - mypy
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
       - name: Install base python for tox
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: "3.9"
       - name: Install tox
@@ -68,20 +81,23 @@ jobs:
         run: tox --skip-pkg-install -e ${{ matrix.tox_env }}
 
   docs:
-    name: docs
-    runs-on: ubuntu-latest
+    name: docs (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false
+      matrix:
+        os:
+          - macos-latest
+          - ubuntu-latest
+          - windows-latest
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
       - name: Install base python for tox
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: "3.9"
-      - name: Install pandoc
-        run: sudo apt-get update -y && sudo apt-get install -y pandoc
       - name: Install tox
         run: python -m pip install tox
       - name: Setup test environment
diff --git a/tests/events/test_events_base.py b/tests/events/test_events_base.py
index e00a0d9..159b6d1 100644
--- a/tests/events/test_events_base.py
+++ b/tests/events/test_events_base.py
@@ -2,6 +2,7 @@
 This module tests functionality of base event classes.
 """
 import pytest
+
 from pymovements.events import Event
 from pymovements.events import Fixation
 from pymovements.events import Saccade
diff --git a/tests/synthetic/test_step_function.py b/tests/synthetic/test_step_function.py
index 5be234d..fff7fe3 100644
--- a/tests/synthetic/test_step_function.py
+++ b/tests/synthetic/test_step_function.py
@@ -1,8 +1,6 @@
 """
 This module tests functionality of the synthetic eye gaze step function.
 """
-
-
 import numpy as np
 import pytest
 
@@ -32,6 +30,26 @@ from pymovements.synthetic import step_function
             {'value': np.concatenate([np.zeros(10), np.ones(40), np.zeros(40), np.ones(10) * 20])},
             id='length_100_3_steps',
         ),
+        pytest.param(
+            {'length': 10, 'steps': [5], 'values': [(1, 2)], 'start_value': 10},
+            {'value': np.concatenate([np.tile(10, (5, 2)), np.tile((1, 2), (5, 1))])},
+            id='length_10_2_channel_single_step_with_single_start_value',
+        ),
+        pytest.param(
+            {'length': 10, 'steps': [5], 'values': [(1, 2)], 'start_value': (11, 12)},
+            {'value': np.concatenate([np.tile((11, 12), (5, 1)), np.tile((1, 2), (5, 1))])},
+            id='length_10_2_channel_single_step_with_2_channel_start_value',
+        ),
+        pytest.param(
+            {'length': 10, 'steps': [5], 'values': [(1, 2, 3, 4)], 'start_value': (11, 12, 13, 14)},
+            {
+                'value': np.concatenate([
+                    np.tile((11, 12, 13, 14), (5, 1)),
+                    np.tile((1, 2, 3, 4), (5, 1)),
+                ]),
+            },
+            id='length_10_4_channel_single_step_with_start_value',
+        ),
         pytest.param(
             {'length': 100, 'steps': [10, 50, 90], 'values': [1, 0], 'start_value': 0},
             {'exception': ValueError},
@@ -42,7 +60,22 @@ from pymovements.synthetic import step_function
             {'exception': ValueError},
             id='steps_not_sorted_raises_value_error',
         ),
-    ]
+        pytest.param(
+            {'length': 10, 'steps': [3, 5], 'values': [1, (2, 3)]},
+            {'exception': ValueError},
+            id='varying_number_of_channels_1_2_raises_value_error',
+        ),
+        pytest.param(
+            {'length': 10, 'steps': [3, 5], 'values': [(1, 2), (3, 5, 6)]},
+            {'exception': ValueError},
+            id='varying_number_of_channels_2_3_raises_value_error',
+        ),
+        pytest.param(
+            {'length': 10, 'steps': [5], 'values': [(1, 2)], 'start_value': (1, 2, 3)},
+            {'exception': ValueError},
+            id='number_of_channels_unequal_start_value_channels_raises_value_error',
+        ),
+    ],
 )
 def test_step_function(params, expected):
     """Test step function."""
@@ -53,4 +86,4 @@ def test_step_function(params, expected):
         return
 
     arr = step_function(**params)
-    assert np.array_equal(arr, expected['value']), f'arr = {arr}, expected = {expected["value"]}'
+    assert np.array_equal(arr, expected['value']), f"arr = {arr}, expected = {expected['value']}"
diff --git a/tests/test_transformations.py b/tests/test_transformations.py
index bc370f8..5ab94ed 100644
--- a/tests/test_transformations.py
+++ b/tests/test_transformations.py
@@ -4,298 +4,466 @@ Test all functions in pymovements.transforms.
 # pylint: disable=missing-function-docstring
 import numpy as np
 import pytest
+
 from pymovements.transforms import pix2deg
 from pymovements.transforms import pos2vel
 
-
-def test_pix2deg_none_coords_raise_type_error():
-    with pytest.raises(TypeError):
-        pix2deg(None, 1, 1, 1)
-
-
-def test_pix2deg_none_screen_px_raise_type_error():
-    with pytest.raises(TypeError):
-        pix2deg(0, None, 1, 1)
-
-
-def test_pix2deg_none_screen_cm_raise_type_error():
-    with pytest.raises(TypeError):
-        pix2deg(0, 1, None, 1)
-
-
-def test_pix2deg_none_distance_cm_raise_type_error():
-    with pytest.raises(TypeError):
-        pix2deg(0, 1, 1, None)
-
-
-def test_pix2deg_zero_screen_px_raise_value_error():
-    with pytest.raises(ValueError):
-        pix2deg(0, 0, 1, 1)
-
-
-def test_pix2deg_zero_screen_cm_raise_value_error():
-    with pytest.raises(ValueError):
-        pix2deg(0, 1, 0.0, 1)
-
-
-def test_pix2deg_zero_distance_cm_raise_value_error():
-    with pytest.raises(ValueError):
-        pix2deg(0, 1, 1, 0.0)
-
-
-def test_pix2deg_rank_3_tensor_raises_value_error():
-    with pytest.raises(ValueError):
-        coords = np.zeros((10, 2, 2))
-        pix2deg(coords, [100, 100], [100, 100], 100, False)
-
-
-def test_pix2deg_zero_coord_without_center_origin_equals_zero():
-    assert pix2deg(0, 100, 100, 100, False) == 0
-
-
-def test_pix2deg_center_coord_with_center_origin_equals_zero():
-    screen_px = 100
-    center_px = (screen_px - 1) / 2
-    assert pix2deg(center_px, screen_px, 10, 10, True) == 0
-
-
-def test_pix2deg_isosceles_triangle_without_center_origin_equals_45():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm / 2
-    coord = screen_px / 2
-
-    assert pix2deg(coord, screen_px, screen_cm, distance_cm, False) == 45
-
-
-def test_pix2deg_isosceles_triangle_with_center_origin_equals_45():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm / 2
-    coord = screen_px - 0.5
-
-    assert pix2deg(coord, screen_px, screen_cm, distance_cm, True) == 45
-
-
-def test_pix2deg_isosceles_triangle_left_with_center_origin_equals_minus45():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm / 2
-    coord = -0.5
-
-    assert pix2deg(coord, screen_px, screen_cm, distance_cm, True) == -45
-
-
-def test_pix2deg_ankathet_half_without_center_origin_equals_26565():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm
-    coord = screen_px / 2
-
-    value = pix2deg(coord, screen_px, screen_cm, distance_cm, False)
-    assert value == pytest.approx(26.565, abs=1e-4)
-
-
-def test_pix2deg_ankathet_half_with_center_origin_equals_26565():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm
-    coord = screen_px - 0.5
-
-    value = pix2deg(coord, screen_px, screen_cm, distance_cm, True)
-    assert value == pytest.approx(26.565, abs=1e-4)
-
-
-def test_pix2deg_ankathet_sqrt_3_without_center_origin_equals_60():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm / 2 / np.sqrt(3)
-    coord = screen_px / 2
-
-    assert (
-        pix2deg(coord, screen_px, screen_cm, distance_cm, False)
-        == pytest.approx(60)
-    )
-
-
-def test_pix2deg_ankathet_sqrt_3_with_center_origin_equals_60():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm / 2 / np.sqrt(3)
-    coord = screen_px - 0.5
-
-    assert (
-        pix2deg(coord, screen_px, screen_cm, distance_cm, True)
-        == pytest.approx(60)
-    )
-
-
-def test_pix2deg_opposite_sqrt_3_without_center_origin_equals_30():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm / 2 * np.sqrt(3)
-    coord = screen_px / 2
-
-    actual_value = pix2deg(coord, screen_px, screen_cm, distance_cm, False)
-    assert actual_value == pytest.approx(30)
-
-
-def test_pix2deg_opposite_sqrt_3_with_center_origin_equals_30():
-    screen_px = 100
-    screen_cm = 100
-    distance_cm = screen_cm / 2 * np.sqrt(3)
-    coord = screen_px - 0.5
-
-    actual_value = pix2deg(coord, screen_px, screen_cm, distance_cm, True)
-    assert actual_value == pytest.approx(30)
-
-
-def test_pix2deg_list_of_zero_coords_1d():
-    n_coords = 10
-    coords = [0] * n_coords
-    control = np.array([0.0] * n_coords)
-    actual = pix2deg(coords, 100, 100, 100, False)
-    assert (control == actual).all()
-
-
-def test_pix2deg_nparray_of_zero_coords_1d():
-    n_coords = 10
-    coords = np.array([0] * n_coords)
-    control = np.array([0.0] * n_coords)
-    actual = pix2deg(coords, 100, 100, 100, False)
-    assert (control == actual).all()
-
-
-def test_pix2deg_list_of_zero_coords_2d():
-    n_coords = 10
-    coords = [[0, 0]] * n_coords
-    control = np.array([[0.0, 0.0]] * n_coords)
-    actual = pix2deg(coords, [100, 100], [100, 100], 100, False)
-    assert (control == actual).all()
-
-
-def test_pix2deg_list_of_zero_coords_2d_screen_px_1d_raise_value_error():
-    n_coords = 10
-    coords = [[0, 0]] * n_coords
-    with pytest.raises(ValueError):
-        pix2deg(coords, 100, [100, 100], 100, False)
-
-
-def test_pix2deg_list_of_zero_coords_2d_screen_cm_1d_raise_value_error():
-    n_coords = 10
-    coords = [[0, 0]] * n_coords
-    with pytest.raises(ValueError):
-        pix2deg(coords, [100, 100], 100, 100, False)
-
-
-def test_pix2deg_nparray_of_isosceles_triangle_without_center_origin_equals_45():
-    screen_px = [100, 100]
-    screen_cm = [100, 100]
-    distance_cm = screen_cm[0] / 2
-    coord = screen_px[0] / 2 * np.ones((10, 2))
-
-    control = 45
-    actual = pix2deg(coord, screen_px, screen_cm, distance_cm, False)
-    assert (actual == control).all()
-
-
-def test_pix2deg_list_1d_screen_px_2d_raise_value_error():
-    with pytest.raises(ValueError):
-        pix2deg([0] * 10, [100, 100], 100, 100)
-
-
-def test_pix2deg_list_1d_screen_cm_2d_raise_value_error():
-    with pytest.raises(ValueError):
-        pix2deg([0] * 10, 100, [100, 100], 100)
-
-
-def test_pix2deg_list_3d_raise_value_error():
-    with pytest.raises(ValueError):
-        pix2deg(np.zeros((10, 3)), [1, 1, 1], [1, 1, 1], 1)
-
-
-def test_pos2vel_sampling_rate_zero_raise_value_error():
-    with pytest.raises(ValueError):
-        pos2vel([0]*10, sampling_rate=0)
-
-
-def test_pos2vel_sampling_rate_less_zero_raise_value_error():
-    with pytest.raises(ValueError):
-        pos2vel([0]*10, sampling_rate=-1)
-
-
-def test_pos2vel_list_length_below_six_method_smooth_raise_value_error():
-    with pytest.raises(ValueError):
-        pos2vel([0]*5, method='smooth')
-
-
-def test_pos2vel_list_length_below_six_method_neighbors_raise_value_error():
-    with pytest.raises(ValueError):
-        pos2vel([0]*2, method='neighbors')
-
-
-def test_pos2vel_list_length_below_six_method_preceding_raise_value_error():
-    with pytest.raises(ValueError):
-        pos2vel([0], method='preceding')
-
-
-def test_pos2vel_invalid_method_raises_value_error():
-    with pytest.raises(ValueError):
-        pos2vel([0]*10, method='invalid')
-
-
-@pytest.mark.parametrize('method', ['smooth', 'neighbors', 'preceding'])
-def test_pos2vel_constant_input_returns_zero_velocity(method):
-    N = 10
-    x = np.repeat(0, N)
-    actual_value = pos2vel(x, sampling_rate=1, method=method)
-    control_value = np.zeros(x.shape)
-
-    assert (actual_value == control_value).all()
-
-
-@pytest.mark.parametrize('method', ['smooth', 'neighbors', 'preceding'])
-def test_pos2vel_linear_input_returns_constant_velocity(method):
-    N = 100
-    x = np.linspace(0, N - 1, N)
-    actual_value = pos2vel(x, sampling_rate=1, method=method)
-    control_value = np.ones(x.shape)
-
-    lpad, rpad = 1, -1
-    assert (actual_value[lpad:rpad] == control_value[lpad:rpad]).all()
-
-
-def test_pos2vel_stepped_input_returns_alternating_velocity_method_preceding():
-    N = 100
-    x = np.linspace(0, N - 2, N // 2)
-    x = np.repeat(x, 2)
-
-    actual_value = pos2vel(x, sampling_rate=1, method='preceding')
-    control_value = np.array([2.0, 0.0] * (N//2))
-
-    lpad, rpad = 1, -1
-    assert (actual_value[lpad:rpad] == control_value[lpad:rpad]).all()
-
-
-def test_pos2vel_stepped_input_returns_constant_velocity_method_neighbors():
+n_coords = 100
+screen_px_1d = 100
+screen_cm_1d = 100
+screen_px_2d = [100, 100]
+screen_cm_2d = [100, 100]
+
+
+@pytest.mark.parametrize(
+    'kwargs, expected_error',
+    [
+        pytest.param(
+            {
+                'arr': None,
+                'screen_px': 1,
+                'screen_cm': 1,
+                'distance_cm': 1
+            },
+            TypeError,
+            id='none_coords_raises_type_error'
+        ),
+        pytest.param(
+            {
+                'arr': 0,
+                'screen_px': None,
+                'screen_cm': 1,
+                'distance_cm': 1
+            },
+            TypeError,
+            id='none_screen_px_raises_type_error'
+        ),
+        pytest.param(
+            {
+                'arr': 0,
+                'screen_px': 1,
+                'screen_cm': None,
+                'distance_cm': 1
+            },
+            TypeError,
+            id='none_screen_cm_raises_type_error'
+        ),
+        pytest.param(
+            {
+                'arr': 0,
+                'screen_px': 1,
+                'screen_cm': 1,
+                'distance_cm': None
+            },
+            TypeError,
+            id='none_distance_cm_raises_type_error'
+        ),
+        pytest.param(
+            {
+                'arr': 0,
+                'screen_px': 0,
+                'screen_cm': 1,
+                'distance_cm': 1
+            },
+            ValueError,
+            id='zero_screen_px_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': 0,
+                'screen_px': 1,
+                'screen_cm': 0,
+                'distance_cm': 1
+            },
+            ValueError,
+            id='zero_screen_cm_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': 0,
+                'screen_px': 1,
+                'screen_cm': 1,
+                'distance_cm': 0
+            },
+            ValueError,
+            id='zero_distance_cm_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': np.zeros((10, 2, 2)),
+                'screen_px': screen_px_2d,
+                'screen_cm': screen_cm_2d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            ValueError,
+            id='rank_3_tensor_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [[0, 0]] * n_coords,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_px_2d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            ValueError,
+            id='list_coords_2d_screen_px_1d_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [[0, 0]] * n_coords,
+                'screen_px': screen_px_2d,
+                'screen_cm': screen_px_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            ValueError,
+            id='list_coords_2d_screen_cm_1d_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [0] * n_coords,
+                'screen_px': screen_px_2d,
+                'screen_cm': screen_px_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            ValueError,
+            id='list_coords_1d_screen_px_2d_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [0] * n_coords,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_px_2d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            ValueError,
+            id='list_coords_1d_screen_cm_2d_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': np.zeros((n_coords, 3)),
+                'screen_px': [1, 1, 1],
+                'screen_cm': [1, 1, 1],
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            ValueError,
+            id='list_coords_3d_raises_value_error'
+        )
+    ]
+)
+def test_pix2deg_raises_error(kwargs, expected_error):
+    with pytest.raises(expected_error):
+        pix2deg(**kwargs)
+
+
+@pytest.mark.parametrize(
+    'kwargs, expected_value',
+    [
+        pytest.param(
+            {
+                'arr': 0,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            0,
+            id='zero_coord_without_center_origin_returns_zero'
+        ),
+        pytest.param(
+            {
+                'arr': (screen_px_1d - 1) / 2,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': True
+            },
+            0,
+            id='center_coord_with_center_origin_returns_zero'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d / 2,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d / 2,
+                'center_origin': False
+            },
+            45,
+            id='isosceles_triangle_without_center_origin_returns_45'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d - 0.5,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d / 2,
+                'center_origin': True
+            },
+            45,
+            id='isosceles_triangle_with_center_origin_returns_45'
+        ),
+        pytest.param(
+            {
+                'arr': -0.5,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d / 2,
+                'center_origin': True
+            },
+            -45,
+            id='isosceles_triangle_left_with_center_origin_returns_minus45'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_2d[0] / 2 * np.ones((n_coords, 2)),
+                'screen_px': screen_px_2d,
+                'screen_cm': screen_cm_2d,
+                'distance_cm': screen_px_2d[0] / 2,
+                'center_origin': False
+            },
+            45,
+            id='nparray_of_isosceles_triangle_without_center_origin_returns_45'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d / 2,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            pytest.approx(26.565, abs=1e-4),
+            id='ankathet_half_without_center_origin_returns_26565'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d - 0.5,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': True
+            },
+            pytest.approx(26.565, abs=1e-4),
+            id='ankathet_half_with_center_origin_returns_26565'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d / 2,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d / 2 / np.sqrt(3),
+                'center_origin': False
+            },
+            pytest.approx(60),
+            id='ankathet_sqrt_3_without_center_origin_returns_60'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d - 0.5,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d / 2 / np.sqrt(3),
+                'center_origin': True
+            },
+            pytest.approx(60),
+            id='ankathet_sqrt_3_with_center_origin_returns_60'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d / 2,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d / 2 * np.sqrt(3),
+                'center_origin': False
+            },
+            pytest.approx(30),
+            id='opposite_sqrt_3_without_center_origin_returns_30'
+        ),
+        pytest.param(
+            {
+                'arr': screen_px_1d - 0.5,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d / 2 * np.sqrt(3),
+                'center_origin': True
+            },
+            pytest.approx(30),
+            id='opposite_sqrt_3_with_center_origin_returns_30'
+        ),
+        pytest.param(
+            {
+                'arr': [0] * n_coords,
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            np.array([0.0] * n_coords),
+            id='list_of_zero_coords_1d'
+        ),
+        pytest.param(
+            {
+                'arr': np.array([0] * n_coords),
+                'screen_px': screen_px_1d,
+                'screen_cm': screen_cm_1d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            np.array([0.0] * n_coords),
+            id='nparray_of_zero_coords_1d'
+        ),
+        pytest.param(
+            {
+                'arr': [[0, 0]] * n_coords,
+                'screen_px': screen_px_2d,
+                'screen_cm': screen_cm_2d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            np.array([[0.0, 0.0]] * n_coords),
+            id='list_of_zero_coords_2d'
+        ),
+        pytest.param(
+            {
+                'arr': np.array([[0, 0]] * n_coords),
+                'screen_px': screen_px_2d,
+                'screen_cm': screen_cm_2d,
+                'distance_cm': screen_cm_1d,
+                'center_origin': False
+            },
+            np.array([[0.0, 0.0]] * n_coords),
+            id='nparray_of_zero_coords_2d'
+        )
+    ]
+)
+def test_pix2deg_returns(kwargs, expected_value):
+    actual_value = pix2deg(**kwargs)
+    assert (actual_value == expected_value).all()
+
+
+@pytest.mark.parametrize(
+    'kwargs, expected_error',
+    [
+        pytest.param(
+            {
+                'arr': [[0] * 10],
+                'sampling_rate': 0
+            },
+            ValueError,
+            id='sampling_rate_zero_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [[0] * 10],
+                'sampling_rate': -1
+            },
+            ValueError,
+            id='sampling_rate_less_zero_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [0] * 5,
+                'method': 'smooth'
+            },
+            ValueError,
+            id='list_length_below_six_method_smooth_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [0] * 2,
+                'method': 'neighbors'
+            },
+            ValueError,
+            id='list_length_below_three_method_neighbors_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [0],
+                'method': 'preceding'
+            },
+            ValueError,
+            id='list_length_below_two_method_preceding_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'arr': [0] * 10,
+                'method': 'invalid'
+            },
+            ValueError,
+            id='invalid_method_raises_value_error'
+        )
+    ]
+)
+def test_pos2vel_raises_error(kwargs, expected_error):
+    with pytest.raises(expected_error):
+        pos2vel(**kwargs)
+
+
+@pytest.mark.parametrize(
+    'method',
+    [
+        pytest.param('smooth', id='method_smooth'),
+        pytest.param('neighbors', id='method_neighbors'),
+        pytest.param('preceding', id='method_preceding')
+    ]
+)
+@pytest.mark.parametrize(
+    'kwargs, padding, expected_value',
+    [
+        pytest.param(
+            {
+                'arr': np.repeat(0, n_coords),
+                'sampling_rate': 1
+            },
+            (0, n_coords),
+            np.zeros(n_coords),
+            id='constant_input_returns_zero_velocity'
+        ),
+        pytest.param(
+            {
+                'arr': np.linspace(0, n_coords - 1, n_coords),
+                'sampling_rate': 1
+            },
+            (1, -1),
+            np.ones(n_coords),
+            id='linear_input_returns_constant_velocity'
+        )
+    ]
+)
+def test_pos2vel_returns(method, kwargs, padding, expected_value):
+    actual_value = pos2vel(**kwargs, method=method)
+    assert (actual_value[padding[0]:padding[1]] == expected_value[padding[0]:padding[1]]).all()
+
+
+@pytest.mark.parametrize(
+    'method, expected_value',
+    [
+        pytest.param(
+            'preceding', np.array([2.0, 0.0] * (100 // 2)),
+            id='method_preceding_alternating_velocity',
+        ),
+        pytest.param(
+            'neighbors', np.ones((100, )),
+            id='method_neighbors_alternating_velocity',
+        ),
+        pytest.param(
+            'smooth', np.ones((100, )),
+            id='method_smooth_alternating_velocity',
+        ),
+    ]
+)
+def test_pos2vel_stepped_input_returns(method, expected_value):
     N = 100
     x = np.linspace(0, N - 2, N // 2)
     x = np.repeat(x, 2)
 
-    actual_value = pos2vel(x, sampling_rate=1, method='neighbors')
-    control_value = np.ones(x.shape)
-
-    lpad, rpad = 1, -1
-    assert (actual_value[lpad:rpad] == control_value[lpad:rpad]).all()
-
-
-def test_pos2vel_stepped_input_returns_constant_velocity_method_smooth():
-    N = 100
-    x = np.linspace(0, N - 2, N // 2)
-    x = np.repeat(x, 2)
-
-    actual_value = pos2vel(x, sampling_rate=1, method='smooth')
-    control_value = np.ones(x.shape)
+    actual_value = pos2vel(x, sampling_rate=1, method=method)
 
     lpad, rpad = 1, -1
-    assert (actual_value[lpad:rpad] == control_value[lpad:rpad]).all()
+    assert (actual_value[lpad:rpad] == expected_value[lpad:rpad]).all()