Repository: aeye-lab/pymovements
Pull request #265 (2023-03-09 18:14:59)

Events: Add EventProcessor and EventGazeProcessor
... took longer than expected but I needed a few iteration to get the architecture right.

## Description

This adds an `EventProcessor` and `EventGazeProcessor` class together with two new properties: `peak_velocity` and `dispersion`.

`EventProcessor` is to be used with an `EventDataFrame`, `EventGazeProcessor` is to be used with an `EventDataFrame` and a `GazeDataFrame` in tandem.

## Implemented changes

- [x] Add `EventProcessor` class to process event data
- [x] Add `EventGazeProcessor` class to process gaze data belonging to events
- [x] Add `peak_velocity()` property
- [x] Add `dispersion()` property
- [x] Replace `EventDataFrame.add_properties()` with `Dataset.compute_event_properties()`
- [x] Add `duration` property to every new `EventDataFrame`

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] This change is or requires a documentation update

## How Has This Been Tested?

- [x] Tested event properties for exceptions
- [x] Tested event properties for expected results
- [x] Tested event processors for exceptions
- [x] Tested event processors for epected results
- [x] Tested `Dataset.compute_event_properties()` for expected results

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings



Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 1537128..5ef4351 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -98,7 +98,6 @@ parallel = true
 branch = true
 omit = [
   'docs/*',
-  'src/pymovements/plotting/*',
   'tests/*'
 ]
 
diff --git a/src/pymovements/datasets/dataset.py b/src/pymovements/datasets/dataset.py
index 3142947..8ee9ac1 100644
--- a/src/pymovements/datasets/dataset.py
+++ b/src/pymovements/datasets/dataset.py
@@ -27,6 +27,7 @@ from typing import Any
 import polars as pl
 from tqdm.auto import tqdm
 
+from pymovements.events.event_processing import EventGazeProcessor
 from pymovements.events.events import EventDataFrame
 from pymovements.events.events import EventDetectionCallable
 from pymovements.gaze import GazeDataFrame
@@ -497,6 +498,39 @@ class Dataset:
                 how='diagonal',
             )
 
+    def compute_event_properties(
+            self,
+            event_properties: str | list[str],
+            verbose: bool = True,
+    ) -> None:
+        """Calculate an event property for and add it as a column to the event dataframe.
+
+        Parameters
+        ----------
+        event_properties:
+            The event properties to compute.
+        verbose : bool
+            If ``True``, show progress bar.
+
+        Raises
+        ------
+        InvalidProperty
+            If ``property_name`` is not a valid property. See
+            :py:mod:`pymovements.events.event_properties` for an overview of supported properties.
+        """
+        processor = EventGazeProcessor(event_properties)
+
+        identifier_columns = [column for column in self.fileinfo.columns if column != 'filepath']
+
+        disable_progressbar = not verbose
+        for events, gaze in tqdm(zip(self.events, self.gaze), disable=disable_progressbar):
+            new_properties = processor.process(events, gaze, identifiers=identifier_columns)
+
+            new_properties = new_properties.drop(identifier_columns)
+            new_properties = new_properties.drop(['name', 'onset', 'offset'])
+
+            events.add_event_properties(new_properties)
+
     def clear_events(self) -> None:
         """Clear event DataFrame."""
         if len(self.events) == 0:
diff --git a/src/pymovements/events/event_processing.py b/src/pymovements/events/event_processing.py
new file mode 100644
index 0000000..544fd70
--- /dev/null
+++ b/src/pymovements/events/event_processing.py
@@ -0,0 +1,192 @@
+# 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.
+"""Module for event processing."""
+from __future__ import annotations
+
+import inspect
+from collections.abc import Callable
+from typing import Any
+
+import polars as pl
+
+from pymovements.events.event_properties import EVENT_PROPERTIES
+from pymovements.events.events import EventDataFrame
+from pymovements.exceptions import InvalidProperty
+from pymovements.gaze.gaze_dataframe import GazeDataFrame
+
+
+class EventProcessor:
+    """Processes event and gaze dataframes.
+
+    Attributes
+    ----------
+    event_properties: list[str]
+        A list of property names.
+    """
+
+    def __init__(self, event_properties: str | list[str]):
+        """Initialize processor with event property definitions.
+
+        Parameters
+        ----------
+        event_properties:
+            List of event property names.
+        """
+        if isinstance(event_properties, str):
+            event_properties = [event_properties]
+
+        for property_name in event_properties:
+            if property_name not in EVENT_PROPERTIES:
+                valid_properties = list(EVENT_PROPERTIES.keys())
+                raise InvalidProperty(
+                    property_name=property_name, valid_properties=valid_properties,
+                )
+
+        self.event_properties = event_properties
+
+    def process(self, events: EventDataFrame) -> pl.DataFrame:
+        """Process event dataframe.
+
+        Parameters
+        ----------
+        events:
+            Event data to process event properties from.
+
+        Returns
+        -------
+        pl.DataFrame
+            :py:class:`polars.DataFrame` with properties as columns and rows refering to the rows in
+            the source dataframe.
+
+        Raises
+        ------
+        InvalidProperty
+            If ``property_name`` is not a valid property. See
+            :py:mod:`pymovements.events.event_properties` for an overview of supported properties.
+        """
+        property_expressions: dict[str, Callable[[], pl.Expr]] = {
+            property_name: EVENT_PROPERTIES[property_name]
+            for property_name in self.event_properties
+        }
+
+        expression_list = [
+            property_expression().alias(property_name)
+            for property_name, property_expression in property_expressions.items()
+        ]
+        result = events.frame.select(expression_list)
+        return result
+
+
+class EventGazeProcessor:
+    """Processes event and gaze dataframes.
+
+    Attributes
+    ----------
+    event_properties: list[str]
+        A list of property names.
+    """
+
+    def __init__(self, event_properties: str | list[str]):
+        """Initialize processor with event property definitions.
+
+        Parameters
+        ----------
+        event_properties:
+            List of event property names.
+        """
+        if isinstance(event_properties, str):
+            event_properties = [event_properties]
+
+        for property_name in event_properties:
+            if property_name not in EVENT_PROPERTIES:
+                valid_properties = list(EVENT_PROPERTIES.keys())
+                raise InvalidProperty(
+                    property_name=property_name, valid_properties=valid_properties,
+                )
+
+        self.event_properties = event_properties
+
+    def process(
+            self,
+            events: EventDataFrame,
+            gaze: GazeDataFrame,
+            identifiers: str | list[str],
+    ) -> pl.DataFrame:
+        """Process event and gaze dataframe.
+
+        Parameters
+        ----------
+        events:
+            Event data to process event properties from.
+        gaze:
+            Gaze data to process event properties from.
+        identifiers:
+            Column names to join on events and gaze dataframes.
+
+        Returns
+        -------
+        pl.DataFrame
+            :py:class:`polars.DataFrame` with properties as columns and rows refering to the rows in
+            the source dataframe.
+
+        Raises
+        ------
+        ValueError
+            If list of identifiers is empty.
+        InvalidProperty
+            If ``property_name`` is not a valid property. See
+            :py:mod:`pymovements.events.event_properties` for an overview of supported properties.
+        """
+        if isinstance(identifiers, str):
+            identifiers = [identifiers]
+        if len(identifiers) == 0:
+            raise ValueError('list of identifiers must not be empty')
+
+        property_expressions: dict[str, Callable[..., pl.Expr]] = {
+            property_name: EVENT_PROPERTIES[property_name]
+            for property_name in self.event_properties
+        }
+
+        property_kwargs: dict[str, dict[str, Any]] = {
+            property_name: {} for property_name in property_expressions.keys()
+        }
+        for property_name, property_expression in property_expressions.items():
+            property_args = inspect.getfullargspec(property_expression).args
+            if 'velocity_columns' in property_args:
+                velocity_columns = tuple(gaze.velocity_columns[:2])
+                property_kwargs[property_name]['velocity_columns'] = velocity_columns
+
+            if 'position_columns' in property_args:
+                position_columns = tuple(gaze.position_columns[:2])
+                property_kwargs[property_name]['position_columns'] = position_columns
+
+        result = (
+            gaze.frame.join(events.frame, on=identifiers)
+            .filter(pl.col('time').is_between(pl.col('onset'), pl.col('offset')))
+            .groupby([*identifiers, 'name', 'onset', 'offset'])
+            .agg(
+                [
+                    property_expression(**property_kwargs[property_name])
+                    .alias(property_name)
+                    for property_name, property_expression in property_expressions.items()
+                ],
+            )
+        )
+        return result
diff --git a/src/pymovements/events/event_properties.py b/src/pymovements/events/event_properties.py
index f5e57b1..ddee9ae 100644
--- a/src/pymovements/events/event_properties.py
+++ b/src/pymovements/events/event_properties.py
@@ -25,19 +25,94 @@ from collections.abc import Callable
 import polars as pl
 
 
-PROPERTIES: dict[str, Callable] = {}
+EVENT_PROPERTIES: dict[str, Callable] = {}
 
 
-def register_property(function: Callable) -> Callable:
+def register_event_property(function: Callable) -> Callable:
     """Register a function as a valid property."""
-    PROPERTIES[function.__name__] = function
+    EVENT_PROPERTIES[function.__name__] = function
     return function
 
 
-@register_property
+@register_event_property
 def duration() -> pl.Expr:
     """Duration of an event.
 
     The duration is defined as the difference between offset time and onset time.
     """
-    return (pl.col('offset') - pl.col('onset')).alias('duration')
+    return pl.col('offset') - pl.col('onset')
+
+
+@register_event_property
+def peak_velocity(velocity_columns: tuple[str, str] = ('x_vel', 'y_vel')) -> pl.Expr:
+    """Peak velocity of an event.
+
+    Parameters
+    ----------
+    velocity_columns
+        The column names of the pitch and yaw velocity components.
+
+    Raises
+    ------
+    TypeError
+        If velocity_columns not of type tuple, velocity_columns not of length 2, or elements of
+        velocity_columns not of type str.
+    """
+    if not isinstance(velocity_columns, tuple):
+        raise TypeError(
+            'velocity_columns must be of type tuple[str, str]'
+            f' but is of type {type(velocity_columns).__name__}',
+        )
+    if len(velocity_columns) != 2:
+        raise TypeError(
+            f'velocity_columns must be of length of 2 but is of length {len(velocity_columns)}',
+        )
+    if not all(isinstance(velocity_column, str) for velocity_column in velocity_columns):
+        raise TypeError(
+            'velocity_columns must be of type tuple[str, str] but is '
+            f'tuple[{type(velocity_columns[0]).__name__}, {type(velocity_columns[1]).__name__}]',
+        )
+
+    x_velocity = velocity_columns[0]
+    y_velocity = velocity_columns[1]
+
+    return (pl.col(x_velocity).pow(2) + pl.col(y_velocity).pow(2)).sqrt().max()
+
+
+@register_event_property
+def dispersion(position_columns: tuple[str, str] = ('x_pos', 'y_pos')) -> pl.Expr:
+    """Dispersion of an event.
+
+    Parameters
+    ----------
+    position_columns
+        The column names of the pitch and yaw position components.
+
+    Raises
+    ------
+    TypeError
+        If position_columns not of type tuple, position_columns not of length 2, or elements of
+        position_columns not of type str.
+    """
+    if not isinstance(position_columns, tuple):
+        raise TypeError(
+            'position_columns must be of type tuple[str, str]'
+            f' but is of type {type(position_columns).__name__}',
+        )
+    if len(position_columns) != 2:
+        raise TypeError(
+            f'position_columns must be of length of 2 but is of length {len(position_columns)}',
+        )
+    if not all(isinstance(velocity_column, str) for velocity_column in position_columns):
+        raise TypeError(
+            'position_columns must be of type tuple[str, str] but is '
+            f'tuple[{type(position_columns[0]).__name__}, {type(position_columns[1]).__name__}]',
+        )
+
+    x_position = position_columns[0]
+    y_position = position_columns[1]
+
+    return (
+        (pl.col(x_position).max() - pl.col(x_position).min())
+        + (pl.col(y_position).max() - pl.col(y_position).min())
+    )
diff --git a/src/pymovements/events/events.py b/src/pymovements/events/events.py
index 02fec90..a265e0d 100644
--- a/src/pymovements/events/events.py
+++ b/src/pymovements/events/events.py
@@ -29,8 +29,7 @@ import numpy as np
 import polars as pl
 from typing_extensions import Protocol
 
-from pymovements import exceptions
-from pymovements.events import event_properties
+from pymovements.events.event_properties import duration
 from pymovements.utils import checks
 
 
@@ -74,9 +73,15 @@ class EventDataFrame:
             checks.check_is_mutual_exclusive(data=data, offsets=offsets)
             checks.check_is_mutual_exclusive(data=data, name=name)
 
+            data = data.clone()
             data = self._add_minimal_schema_columns(data)
             data_dict = data.to_dict()
 
+            self._additional_columns = [
+                column_name for column_name in data_dict.keys()
+                if column_name not in self._minimal_schema
+            ]
+
         else:
             # Make sure that if either onsets or offsets is None, the other one is None too.
             checks.check_is_none_is_mutual(onsets=onsets, offsets=offsets)
@@ -109,36 +114,8 @@ class EventDataFrame:
                 }
 
         self.frame = pl.DataFrame(data=data_dict, schema_overrides=self._minimal_schema)
-
-    def add_property(self, property_name: str) -> EventDataFrame:
-        """Add an event property column to the event dataframe.
-
-        Parameters
-        ----------
-        property_name: str
-            The name of the property
-
-        Returns
-        -------
-        EventDataFrame
-            Reference to ``self`` to enable method chaining.
-
-        Raises
-        ------
-        InvalidProperty
-            If ``property_name`` is not a valid property. See
-            :py:mod:`pymovements.events.event_properties` for an overview of supported properties.
-        """
-        property_expression = event_properties.PROPERTIES.get(property_name, None)
-
-        if property_expression is None:
-            valid_properties = list(event_properties.PROPERTIES.keys())
-            raise exceptions.InvalidProperty(
-                property_name=property_name, valid_properties=valid_properties,
-            )
-
-        self.frame = self.frame.select([pl.all(), property_expression()])
-        return self
+        if 'duration' not in self.frame.columns:
+            self._add_duration_property()
 
     @property
     def schema(self) -> pl.datatypes.SchemaDict:
@@ -156,6 +133,28 @@ class EventDataFrame:
         """List of column names."""
         return self.frame.columns
 
+    def _add_duration_property(self):
+        """Adds duration property column to dataframe."""
+        self.frame = self.frame.select([pl.all(), duration().alias('duration')])
+
+    def add_event_properties(self, event_properties: pl.DataFrame) -> None:
+        """Add new event properties into dataframe.
+
+        Parameters
+        ----------
+        event_properties
+            Dataframe with new event properties.
+        """
+        self.frame = self.frame.select([pl.all(), *event_properties])
+
+    @property
+    def event_property_columns(self) -> list[str]:
+        """Event property columns for this dataframe."""
+        event_property_columns = set(self.frame.columns)
+        event_property_columns -= set(list(self._minimal_schema.keys()))
+        event_property_columns -= set(self._additional_columns)
+        return list(event_property_columns)
+
     def _add_minimal_schema_columns(self, df: pl.DataFrame) -> pl.DataFrame:
         """Add minimal schema columns to :py:class:`polars.DataFrame` if they are missing."""
         if len(df) == 0:
diff --git a/src/pymovements/exceptions.py b/src/pymovements/exceptions.py
index df36f8a..afe3123 100644
--- a/src/pymovements/exceptions.py
+++ b/src/pymovements/exceptions.py
@@ -27,8 +27,3 @@ class InvalidProperty(Exception):
     def __init__(self, property_name: str, valid_properties: list[str]):
         message = f"property '{property_name}' is invalid. Valid properties are: {valid_properties}"
         super().__init__(message)
-
-
-class MissingColumns(Exception):
-    """Raised if expected columns are not available in the particular
-    :py:class:`polars.DataFrame`."""
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index 4c8425b..538d307 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -44,6 +44,12 @@ class GazeDataFrame:
         'x_right_pos', 'y_right_pos',
     ]
 
+    _valid_velocity_columns = [
+        'x_vel', 'y_vel',
+        'x_left_vel', 'y_left_vel',
+        'x_right_vel', 'y_right_vel',
+    ]
+
     def __init__(
             self,
             data: pl.DataFrame | None = None,
@@ -158,12 +164,7 @@ class GazeDataFrame:
     @property
     def velocity_columns(self) -> list[str]:
         """Velocity columns (in degrees of visual angle per second) of dataframe."""
-        velocity_columns = [
-            'x_vel', 'y_vel',
-            'x_left_vel', 'y_left_vel',
-            'x_right_vel', 'y_right_vel',
-        ]
-        velocity_columns = list(set(velocity_columns) & set(self.frame.columns))
+        velocity_columns = list(set(self._valid_velocity_columns) & set(self.frame.columns))
         return velocity_columns
 
     @property
diff --git a/src/pymovements/plotting/traceplot.py b/src/pymovements/plotting/traceplot.py
index a685209..f530283 100644
--- a/src/pymovements/plotting/traceplot.py
+++ b/src/pymovements/plotting/traceplot.py
@@ -18,7 +18,7 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """
-This module holds all plotting functions.
+This module holds the traceplot.
 """
 from __future__ import annotations
 
@@ -28,7 +28,6 @@ import numpy as np
 from matplotlib import colors
 from matplotlib.collections import LineCollection
 
-
 default_segmentdata = {
     'red': [
         [0.0, 0.0, 0.0],
@@ -101,7 +100,7 @@ def traceplot(
     cmap: matplotlib.colors.Colormap, optional
         color map for line color values
     cmap_norm: matplotlib.colors.Normalize, str, optional
-        normalization for color values
+        normalization for color values.
     cmap_segmentdata: dict, optional
         color map segmentation to build color map
     cbar_label: str, optional
@@ -124,7 +123,8 @@ def traceplot(
     Raises
     ------
     ValueError
-        If length of x and y coordinates do not match.
+        If length of x and y coordinates do not match or if ``cmap_norm`` is unknown.
+
     """
 
     if len(x) != len(y):
@@ -141,10 +141,10 @@ def traceplot(
         cval = np.zeros(n)
         show_cbar = False
 
-    if cmap_norm is None:
-        cval_max = np.nanmax(np.abs(cval))
-        cval_min = np.nanmin(cval)
+    cval_max = np.nanmax(np.abs(cval))
+    cval_min = np.nanmin(cval)
 
+    if cmap_norm is None:
         if cval_max and cval_min < 0:
             cmap_norm = 'twoslope'
         elif cval_max:
@@ -174,6 +174,16 @@ def traceplot(
     elif cmap_norm == 'nonorm':
         cmap_norm = matplotlib.colors.NoNorm()
 
+    elif isinstance(cmap_norm, str):
+        # pylint: disable=protected-access
+        scale_class = matplotlib.scale._scale_mapping.get(cmap_norm, None)
+
+        if scale_class is None:
+            raise ValueError(f'cmap_norm string {cmap_norm} is not supported')
+
+        norm_class = matplotlib.colors.make_norm_from_scale(scale_class)
+        cmap_norm = norm_class(matplotlib.colors.Normalize)()
+
     # Create a set of line segments so that we can color them individually
     # This creates the points as a N x 1 x 2 array so that we can stack points
     # together easily to get the segments. The segments array for line collection
diff --git a/src/pymovements/plotting/tsplot.py b/src/pymovements/plotting/tsplot.py
index 4a445b2..bd3b0cd 100644
--- a/src/pymovements/plotting/tsplot.py
+++ b/src/pymovements/plotting/tsplot.py
@@ -18,7 +18,7 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """
-This module holds all plotting functions.
+This module holds the time series plot.
 """
 from __future__ import annotations
 
@@ -119,11 +119,20 @@ def tsplot(
         ylims = -ylim_abs * y_pad_factor, ylim_abs * y_pad_factor
 
     for channel_id in range(n_channels):
-        ax = axs[channel_id]
+        if n_channels == 1:
+            ax = axs
+        else:
+            ax = axs[channel_id]
+
         x_channel = arr[channel_id, :]
 
         ax.plot(t, x_channel, color=line_color, linewidth=line_width)
 
+        if not share_y:
+            y_pad_factor = 1.1
+            ylim_abs = np.nanmax(np.abs(arr[channel_id]))
+            ylims = -ylim_abs * y_pad_factor, ylim_abs * y_pad_factor
+
         ax.set_xlim(xlims)
         ax.set_ylim(ylims)
 
@@ -161,7 +170,10 @@ def tsplot(
     # print x label on last used (bottom) axis
     ax.set_xlabel(xlabel)
 
-    axs[0].set_title(title)
+    if n_channels == 1:
+        ax.set_title(title)
+    else:
+        axs[0].set_title(title)
 
     if savepath is not None:
         fig.savefig(savepath)


Test patch:
diff --git a/tests/datasets/dataset_test.py b/tests/datasets/dataset_test.py
index 265d152..2318810 100644
--- a/tests/datasets/dataset_test.py
+++ b/tests/datasets/dataset_test.py
@@ -27,6 +27,7 @@ import polars as pl
 import pytest
 from polars.testing import assert_frame_equal
 
+from pymovements import exceptions
 from pymovements.datasets.dataset import Dataset
 from pymovements.events.engbert import microsaccades
 from pymovements.events.events import EventDataFrame
@@ -207,10 +208,17 @@ def mock_toy(rootpath, raw_fileformat, eyes):
             {
                 'subject_id': fileinfo_row['subject_id'],
                 'name': 'saccade',
-                'onset': np.arange(0, 901, 100),
-                'offset': np.arange(10, 911, 100),
+                'onset': np.arange(0, 100, 10),
+                'offset': np.arange(5, 105, 10),
+                'duration': np.array([5] * 10),
+            },
+            schema={
+                'subject_id': pl.Int64,
+                'name': pl.Utf8,
+                'onset': pl.Int64,
+                'offset': pl.Int64,
+                'duration': pl.Int64,
             },
-            schema={'subject_id': pl.Int64, 'name': pl.Utf8, 'onset': pl.Int64, 'offset': pl.Int64},
         )
         event_dfs.append(event_df)
 
@@ -462,7 +470,9 @@ def test_detect_events_auto_eye(detect_event_kwargs, dataset_configuration):
     dataset.pos2vel()
     dataset.detect_events(**detect_event_kwargs)
 
-    expected_schema = {'subject_id': pl.Int64, **EventDataFrame._minimal_schema}
+    expected_schema = {
+        'subject_id': pl.Int64, **EventDataFrame._minimal_schema, 'duration': pl.Int64,
+    }
     for result_event_df in dataset.events:
         assert result_event_df.schema == expected_schema
 
@@ -512,7 +522,9 @@ def test_detect_events_explicit_eye(detect_event_kwargs, dataset_configuration):
     if exception is None:
         dataset.detect_events(**detect_event_kwargs)
 
-        expected_schema = {'subject_id': pl.Int64, **EventDataFrame._minimal_schema}
+        expected_schema = {
+            'subject_id': pl.Int64, **EventDataFrame._minimal_schema, 'duration': pl.Int64,
+        }
 
         for result_event_df in dataset.events:
             assert result_event_df.schema == expected_schema
@@ -536,7 +548,7 @@ def test_detect_events_explicit_eye(detect_event_kwargs, dataset_configuration):
                 'threshold': 1,
                 'eye': 'auto',
             },
-            {'subject_id': pl.Int64, **EventDataFrame._minimal_schema},
+            {'subject_id': pl.Int64, **EventDataFrame._minimal_schema, 'duration': pl.Int64},
             id='two-saccade-runs',
         ),
         pytest.param(
@@ -550,7 +562,7 @@ def test_detect_events_explicit_eye(detect_event_kwargs, dataset_configuration):
                 'velocity_threshold': 1,
                 'minimum_duration': 1,
             },
-            {'subject_id': pl.Int64, **EventDataFrame._minimal_schema},
+            {'subject_id': pl.Int64, **EventDataFrame._minimal_schema, 'duration': pl.Int64},
             id='one-saccade-one-fixation-run',
         ),
     ],
@@ -953,3 +965,95 @@ def test_velocity_columns(dataset_configuration):
 
     for gaze_df in dataset.gaze:
         case.assertCountEqual(gaze_df.velocity_columns, expected_velocity_columns)
+
+
+@pytest.mark.parametrize(
+    ('property_kwargs', 'exception', 'msg_substrings'),
+    [
+        pytest.param(
+            {'event_properties': 'foo'},
+            exceptions.InvalidProperty,
+            ('foo', 'invalid', 'valid', 'peak_velocity'),
+            id='invalid_property',
+        ),
+    ],
+)
+def test_event_dataframe_add_property_raises_exceptions(
+        dataset_configuration, property_kwargs, exception, msg_substrings,
+):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(preprocessed=True, events=True)
+
+    with pytest.raises(exception) as excinfo:
+        dataset.compute_event_properties(**property_kwargs)
+
+    msg, = excinfo.value.args
+    for msg_substring in msg_substrings:
+        assert msg_substring.lower() in msg.lower()
+
+
+@pytest.mark.parametrize(
+    'property_kwargs',
+    [
+        pytest.param({'event_properties': 'peak_velocity'}, id='peak_velocity'),
+    ],
+)
+def test_event_dataframe_add_property_has_expected_height(dataset_configuration, property_kwargs):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(preprocessed=True, events=True)
+
+    expected_heights = [len(event_df) for event_df in dataset.events]
+
+    dataset.compute_event_properties(**property_kwargs)
+
+    for events_df, expected_height in zip(dataset.events, expected_heights):
+        assert events_df.frame.height == expected_height
+
+
+@pytest.mark.parametrize(
+    ('property_kwargs', 'expected_schema'),
+    [
+        pytest.param(
+            {'event_properties': 'peak_velocity'},
+            {
+                'subject_id': pl.Int64,
+                **EventDataFrame._minimal_schema,
+                'duration': pl.Int64,
+                'peak_velocity': pl.Float64,
+            },
+            id='single_event_peak_velocity',
+        ),
+    ],
+)
+def test_event_dataframe_add_property_has_expected_schema(
+        dataset_configuration, property_kwargs, expected_schema,
+):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(preprocessed=True, events=True)
+
+    dataset.compute_event_properties(**property_kwargs)
+
+    for events_df in dataset.events:
+        assert events_df.frame.schema == expected_schema
+
+
+@pytest.mark.parametrize(
+    ('property_kwargs', 'expected_property_columns'),
+    [
+        pytest.param(
+            {'event_properties': 'peak_velocity'},
+            ['peak_velocity'],
+            id='single_event_peak_velocity',
+        ),
+    ],
+)
+def test_event_dataframe_add_property_effect_property_columns(
+        dataset_configuration, property_kwargs, expected_property_columns,
+):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(preprocessed=True, events=True)
+
+    dataset.compute_event_properties(**property_kwargs)
+
+    for events_df in dataset.events:
+        assert events_df.event_property_columns == expected_property_columns
diff --git a/tests/events/event_dataframe_test.py b/tests/events/event_dataframe_test.py
index 6e7322d..6ab16a1 100644
--- a/tests/events/event_dataframe_test.py
+++ b/tests/events/event_dataframe_test.py
@@ -22,13 +22,12 @@ import polars as pl
 import pytest
 from polars.testing import assert_frame_equal
 
-from pymovements import exceptions
 from pymovements.events.events import EventDataFrame
 
 
-@pytest.fixture(name='minimal_schema')
+@pytest.fixture(name='expected_schema_after_init')
 def fixture_dataset():
-    schema = {'name': pl.Utf8, 'onset': pl.Int64, 'offset': pl.Int64}
+    schema = {'name': pl.Utf8, 'onset': pl.Int64, 'offset': pl.Int64, 'duration': pl.Int64}
     yield schema
 
 
@@ -90,9 +89,9 @@ def test_event_dataframe_init_exceptions(kwargs, exception, msg_substrings):
         pytest.param([], {'onsets': [0, 2], 'offsets': [1, 3]}, id='dict_with_two_events_kwarg'),
     ],
 )
-def test_event_dataframe_init_minimal_schema(args, kwargs, minimal_schema):
+def test_event_dataframe_init_expected_schema(args, kwargs, expected_schema_after_init):
     event_df = EventDataFrame(*args, **kwargs)
-    assert event_df.schema == minimal_schema
+    assert event_df.schema == expected_schema_after_init
 
 
 @pytest.mark.parametrize(
@@ -150,7 +149,7 @@ def test_event_dataframe_init_has_correct_names(args, kwargs, expected_names):
     [
         pytest.param(
             [], {'onsets': [0], 'offsets': [1]},
-            {'name': [''], 'onset': [0], 'offset': [1]},
+            {'name': [''], 'onset': [0], 'offset': [1], 'duration': [1]},
             id='no_arg_dict_with_single_event_kwarg',
         ),
         pytest.param(
@@ -160,83 +159,28 @@ def test_event_dataframe_init_has_correct_names(args, kwargs, expected_names):
         ),
         pytest.param(
             [], {'name': 'bar', 'onsets': [0], 'offsets': [1]},
-            {'name': ['bar'], 'onset': [0], 'offset': [1]},
+            {'name': ['bar'], 'onset': [0], 'offset': [1], 'duration': [1]},
             id='dict_with_single_named_event',
         ),
         pytest.param(
             [], {'name': 'bar', 'onsets': [0, 2], 'offsets': [1, 3]},
-            {'name': ['bar', 'bar'], 'onset': [0, 2], 'offset': [1, 3]},
+            {'name': ['bar', 'bar'], 'onset': [0, 2], 'offset': [1, 3], 'duration': [1, 1]},
             id='dict_with_two_events_same_name',
         ),
         pytest.param(
-            [], {'name': ['foo', 'bar'], 'onsets': [0, 2], 'offsets': [1, 3]},
-            {'name': ['foo', 'bar'], 'onset': [0, 2], 'offset': [1, 3]},
+            [], {'name': ['foo', 'bar'], 'onsets': [0, 2], 'offsets': [1, 4]},
+            {'name': ['foo', 'bar'], 'onset': [0, 2], 'offset': [1, 4], 'duration': [1, 2]},
             id='dict_with_two_differently_named_events',
         ),
     ],
 )
-def test_event_dataframe_init_expected(args, kwargs, expected_df_data, minimal_schema):
+def test_event_dataframe_init_expected(args, kwargs, expected_df_data, expected_schema_after_init):
     event_df = EventDataFrame(*args, **kwargs)
 
-    expected_df = pl.DataFrame(data=expected_df_data, schema=minimal_schema)
+    expected_df = pl.DataFrame(data=expected_df_data, schema=expected_schema_after_init)
     assert_frame_equal(event_df.frame, expected_df)
 
 
-@pytest.mark.parametrize(
-    ('init_kwargs', 'property_kwargs', 'exception', 'msg_substrings'),
-    [
-        pytest.param(
-            {},
-            {'property_name': 'foo'},
-            exceptions.InvalidProperty,
-            ('foo', 'invalid', 'valid', 'duration'),
-            id='invalid_property',
-        ),
-    ],
-)
-def test_event_dataframe_add_property_raises_exceptions(
-        init_kwargs, property_kwargs, exception, msg_substrings,
-):
-    event_df = EventDataFrame(**init_kwargs)
-
-    with pytest.raises(exception) as excinfo:
-        event_df.add_property(**property_kwargs)
-
-    msg, = excinfo.value.args
-    for msg_substring in msg_substrings:
-        assert msg_substring.lower() in msg.lower()
-
-
-@pytest.mark.parametrize(
-    ('init_kwargs', 'property_kwargs', 'expected_df'),
-    [
-        pytest.param(
-            {'onsets': [0], 'offsets': [1]},
-            {'property_name': 'duration'},
-            EventDataFrame(pl.DataFrame({'name': '', 'onset': 0, 'offset': 1, 'duration': 1})),
-            id='single_event_duration_property',
-        ),
-        pytest.param(
-            {'onsets': [0, 100], 'offsets': [1, 111]},
-            {'property_name': 'duration'},
-            EventDataFrame(
-                pl.DataFrame(
-                    {'name': ['', ''], 'onset': [0, 100], 'offset': [1, 111], 'duration': [1, 11]},
-                ),
-            ),
-            id='two_events_duration_property',
-        ),
-    ],
-)
-def test_event_dataframe_add_property_has_expected_result(
-        init_kwargs, property_kwargs, expected_df,
-):
-    event_df = EventDataFrame(**init_kwargs)
-    event_df.add_property(**property_kwargs)
-
-    assert_frame_equal(event_df.frame, expected_df.frame)
-
-
 def test_event_dataframe_columns_same_as_frame():
     init_kwargs = {'onsets': [0], 'offsets': [1]}
     event_df = EventDataFrame(**init_kwargs)
diff --git a/tests/events/event_processing_test.py b/tests/events/event_processing_test.py
new file mode 100644
index 0000000..c4f09e9
--- /dev/null
+++ b/tests/events/event_processing_test.py
@@ -0,0 +1,352 @@
+# 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 event processing classes."""
+import numpy as np
+import polars as pl
+import pytest
+from polars.testing import assert_frame_equal
+
+from pymovements.events.event_processing import EventGazeProcessor
+from pymovements.events.event_processing import EventProcessor
+from pymovements.events.events import EventDataFrame
+from pymovements.exceptions import InvalidProperty
+from pymovements.gaze.gaze_dataframe import GazeDataFrame
+
+
+@pytest.mark.parametrize(
+    ('args', 'kwargs', 'expected_property_definitions'),
+    [
+        pytest.param(['duration'], {}, ['duration'], id='arg_str_duration'),
+        pytest.param([['duration']], {}, ['duration'], id='arg_list_duration'),
+        pytest.param(
+            [], {'event_properties': 'duration'}, ['duration'],
+            id='kwarg_properties_duration',
+        ),
+    ],
+)
+def test_event_processor_init(args, kwargs, expected_property_definitions):
+    processor = EventProcessor(*args, **kwargs)
+
+    assert processor.event_properties == expected_property_definitions
+
+
+@pytest.mark.parametrize(
+    ('args', 'kwargs', 'exception', 'msg_substrings'),
+    [
+        pytest.param(
+            ['foo'], {},
+            InvalidProperty, ('foo', 'invalid', 'duration'),
+            id='unknown_event_property',
+        ),
+    ],
+)
+def test_event_processor_init_exceptions(args, kwargs, exception, msg_substrings):
+    with pytest.raises(exception) as excinfo:
+        EventProcessor(*args, **kwargs)
+
+    msg, = excinfo.value.args
+    for msg_substring in msg_substrings:
+        assert msg_substring.lower() in msg.lower()
+
+
+@pytest.mark.parametrize(
+    ('events_kwargs', 'event_properties', 'expected_dataframe'),
+    [
+        pytest.param(
+            {'onsets': [], 'offsets': []},
+            'duration',
+            pl.DataFrame(schema={'duration': pl.Int64}),
+            id='duration_no_event',
+        ),
+        pytest.param(
+            {'onsets': [0], 'offsets': [1]},
+            'duration',
+            pl.DataFrame(data=[1], schema={'duration': pl.Int64}),
+
+            id='duration_single_event',
+        ),
+        pytest.param(
+            {'onsets': [0, 100], 'offsets': [1, 111]},
+            'duration',
+            pl.DataFrame(data=[1, 11], schema={'duration': pl.Int64}),
+            id='duration_two_events',
+        ),
+    ],
+)
+def test_event_processor_process_correct_result(
+        events_kwargs, event_properties, expected_dataframe,
+):
+    events = EventDataFrame(**events_kwargs)
+    processor = EventProcessor(event_properties)
+
+    property_result = processor.process(events)
+    assert_frame_equal(property_result, expected_dataframe)
+
+
+@pytest.mark.parametrize(
+    ('args', 'kwargs', 'expected_property_definitions'),
+    [
+        pytest.param(['peak_velocity'], {}, ['peak_velocity'], id='arg_str_peak_velocity'),
+        pytest.param([['peak_velocity']], {}, ['peak_velocity'], id='arg_list_peak_velocity'),
+        pytest.param(
+            [], {'event_properties': 'peak_velocity'}, ['peak_velocity'],
+            id='kwarg_properties_peak_velocity',
+        ),
+    ],
+)
+def test_event_gaze_processor_init(args, kwargs, expected_property_definitions):
+    processor = EventGazeProcessor(*args, **kwargs)
+
+    assert processor.event_properties == expected_property_definitions
+
+
+@pytest.mark.parametrize(
+    ('args', 'kwargs', 'exception', 'msg_substrings'),
+    [
+        pytest.param(
+            ['foo'], {},
+            InvalidProperty, ('foo', 'invalid', 'peak_velocity'),
+            id='unknown_event_property',
+        ),
+    ],
+)
+def test_event_gaze_processor_init_exceptions(args, kwargs, exception, msg_substrings):
+    with pytest.raises(exception) as excinfo:
+        EventGazeProcessor(*args, **kwargs)
+
+    msg, = excinfo.value.args
+    for msg_substring in msg_substrings:
+        assert msg_substring.lower() in msg.lower()
+
+
+@pytest.mark.parametrize(
+    ('event_df', 'gaze_df', 'init_kwargs', 'process_kwargs', 'expected_dataframe'),
+    [
+        pytest.param(
+            pl.from_dict(
+                {'subject_id': [1], 'onset': [0], 'offset': [10]},
+                schema={'subject_id': pl.Int64, 'onset': pl.Int64, 'offset': pl.Int64},
+            ),
+            pl.from_dict(
+                {
+                    'subject_id': np.ones(10),
+                    'time': np.arange(10),
+                    'x_vel': np.ones(10),
+                    'y_vel': np.zeros(10),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_vel': pl.Float64,
+                    'y_vel': pl.Float64,
+                },
+            ),
+            {'event_properties': 'peak_velocity'},
+            {'identifiers': 'subject_id'},
+            pl.from_dict(
+                {
+                    'subject_id': [1],
+                    'name': [None],
+                    'onset': [0],
+                    'offset': [10],
+                    'peak_velocity': [1],
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'name': pl.Utf8,
+                    'onset': pl.Int64,
+                    'offset': pl.Int64,
+                    'peak_velocity': pl.Float64,
+                },
+            ),
+            id='peak_velocity_single_event_complete_window',
+        ),
+        pytest.param(
+            pl.from_dict(
+                {'subject_id': [1], 'onset': [0], 'offset': [10]},
+                schema={'subject_id': pl.Int64, 'onset': pl.Int64, 'offset': pl.Int64},
+            ),
+            pl.from_dict(
+                {
+                    'subject_id': np.ones(10),
+                    'time': np.arange(10),
+                    'x_vel': np.ones(10),
+                    'y_vel': np.zeros(10),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_vel': pl.Float64,
+                    'y_vel': pl.Float64,
+                },
+            ),
+            {'event_properties': 'peak_velocity'},
+            {'identifiers': 'subject_id'},
+            pl.from_dict(
+                {
+                    'subject_id': [1],
+                    'name': [None],
+                    'onset': [0],
+                    'offset': [10],
+                    'peak_velocity': [1],
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'name': pl.Utf8,
+                    'onset': pl.Int64,
+                    'offset': pl.Int64,
+                    'peak_velocity': pl.Float64,
+                },
+            ),
+            id='peak_velocity_custom_columns_single_event_complete_window',
+        ),
+        pytest.param(
+            pl.from_dict(
+                {'subject_id': [1], 'onset': [0], 'offset': [5]},
+                schema={'subject_id': pl.Int64, 'onset': pl.Int64, 'offset': pl.Int64},
+            ),
+            pl.from_dict(
+                {
+                    'subject_id': np.ones(10),
+                    'time': np.arange(10),
+                    'x_vel': np.concatenate([np.ones(5), np.zeros(5)]),
+                    'y_vel': np.zeros(10),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_vel': pl.Float64,
+                    'y_vel': pl.Float64,
+                },
+            ),
+            {'event_properties': 'peak_velocity'},
+            {'identifiers': 'subject_id'},
+            pl.from_dict(
+                {
+                    'subject_id': [1],
+                    'name': [None],
+                    'onset': [0],
+                    'offset': [5],
+                    'peak_velocity': [1],
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'name': pl.Utf8,
+                    'onset': pl.Int64,
+                    'offset': pl.Int64,
+                    'peak_velocity': pl.Float64,
+                },
+            ),
+            id='peak_velocity_single_event_half_window',
+        ),
+        pytest.param(
+            pl.from_dict(
+                {'subject_id': [1], 'onset': [0], 'offset': [10]},
+                schema={'subject_id': pl.Int64, 'onset': pl.Int64, 'offset': pl.Int64},
+            ),
+            pl.from_dict(
+                {
+                    'subject_id': np.ones(10),
+                    'time': np.arange(10),
+                    'x_pos': np.concatenate([np.ones(5), np.zeros(5)]),
+                    'y_pos': np.concatenate([np.zeros(5), np.ones(5)]),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_pos': pl.Float64,
+                    'y_pos': pl.Float64,
+                },
+            ),
+            {'event_properties': 'dispersion'},
+            {'identifiers': 'subject_id'},
+            pl.from_dict(
+                {
+                    'subject_id': [1],
+                    'name': [None],
+                    'onset': [0],
+                    'offset': [10],
+                    'dispersion': [2],
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'name': pl.Utf8,
+                    'onset': pl.Int64,
+                    'offset': pl.Int64,
+                    'dispersion': pl.Float64,
+                },
+            ),
+            id='dispersion_single_event_complete_window',
+        ),
+    ],
+)
+def test_event_gaze_processor_process_correct_result(
+        event_df, gaze_df, init_kwargs, process_kwargs, expected_dataframe,
+):
+    processor = EventGazeProcessor(**init_kwargs)
+    events = EventDataFrame(event_df)
+    gaze = GazeDataFrame(gaze_df)
+    property_result = processor.process(events, gaze, **process_kwargs)
+    assert_frame_equal(property_result, expected_dataframe)
+
+
+@pytest.mark.parametrize(
+    ('event_df', 'gaze_df', 'init_kwargs', 'process_kwargs', 'exception', 'msg_substrings'),
+    [
+        pytest.param(
+            pl.from_dict(
+                {'subject_id': [1], 'onset': [0], 'offset': [10]},
+                schema={'subject_id': pl.Int64, 'onset': pl.Int64, 'offset': pl.Int64},
+            ),
+            pl.from_dict(
+                {
+                    'subject_id': np.ones(10),
+                    'time': np.arange(10),
+                    'x_vel': np.ones(10),
+                    'y_vel': np.zeros(10),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_vel': pl.Float64,
+                    'y_vel': pl.Float64,
+                },
+            ),
+            {'event_properties': 'peak_velocity'},
+            {'identifiers': []},
+            ValueError,
+            ('identifiers', 'list', 'must', 'not', 'empty'),
+            id='empty_list',
+        ),
+    ],
+)
+def test_event_processor_process_exceptions(
+        event_df, gaze_df, init_kwargs, process_kwargs, exception, msg_substrings,
+):
+    processor = EventGazeProcessor(**init_kwargs)
+    events = EventDataFrame(event_df)
+    gaze = GazeDataFrame(gaze_df)
+
+    with pytest.raises(exception) as excinfo:
+        processor.process(events, gaze, **process_kwargs)
+
+    msg, = excinfo.value.args
+    for msg_substring in msg_substrings:
+        assert msg_substring.lower() in msg.lower()
diff --git a/tests/events/event_properties_test.py b/tests/events/event_properties_test.py
index 186f5aa..0b70b2c 100644
--- a/tests/events/event_properties_test.py
+++ b/tests/events/event_properties_test.py
@@ -22,30 +22,121 @@ import polars as pl
 import pytest
 from polars.testing import assert_frame_equal
 
-from pymovements.events.event_properties import duration
-from pymovements.events.event_properties import PROPERTIES
+from pymovements.events import event_properties
+from pymovements.events.event_properties import EVENT_PROPERTIES
 
 
 @pytest.mark.parametrize(
-    ('input_df', 'exception', 'msg_substrings'),
+    ('event_property', 'init_kwargs', 'exception', 'msg_substrings'),
     [
         pytest.param(
+            event_properties.peak_velocity,
+            {'velocity_columns': 'x_vel'},
+            TypeError,
+            ('velocity_columns', 'type', 'must', 'tuple', 'str'),
+            id='peak_velocity_str',
+        ),
+        pytest.param(
+            event_properties.peak_velocity,
+            {'velocity_columns': 1},
+            TypeError,
+            ('velocity_columns', 'type', 'must', 'tuple', 'int'),
+            id='peak_velocity_int',
+        ),
+        pytest.param(
+            event_properties.peak_velocity,
+            {'velocity_columns': ('x_vel', 'y_vel', 'z_vel')},
+            TypeError,
+            ('velocity_columns', 'length', 'must', '2', '3'),
+            id='peak_velocity_tuple_length_3',
+        ),
+        pytest.param(
+            event_properties.peak_velocity,
+            {'velocity_columns': ('x_vel', 1)},
+            TypeError,
+            ('velocity_columns', 'type', 'must', 'tuple', 'str', 'int'),
+            id='peak_velocity_tuple_of_int',
+        ),
+        pytest.param(
+            event_properties.dispersion,
+            {'position_columns': 'x_pos'},
+            TypeError,
+            ('position_columns', 'type', 'must', 'tuple', 'str'),
+            id='duration_str',
+        ),
+        pytest.param(
+            event_properties.dispersion,
+            {'position_columns': 1},
+            TypeError,
+            ('position_columns', 'type', 'must', 'tuple', 'int'),
+            id='duration_int',
+        ),
+        pytest.param(
+            event_properties.dispersion,
+            {'position_columns': ('x_pos', 'y_pos', 'z_pos')},
+            TypeError,
+            ('position_columns', 'length', 'must', '2', '3'),
+            id='duration_tuple_length_3',
+        ),
+        pytest.param(
+            event_properties.dispersion,
+            {'position_columns': ('x_pos', 1)},
+            TypeError,
+            ('position_columns', 'type', 'must', 'tuple', 'str', 'int'),
+            id='duration_tuple_of_int',
+        ),
+    ],
+)
+def test_property_init_exceptions(event_property, init_kwargs, exception, msg_substrings):
+    with pytest.raises(exception) as excinfo:
+        event_property(**init_kwargs)
+
+    msg, = excinfo.value.args
+    for msg_substring in msg_substrings:
+        assert msg_substring.lower() in msg.lower()
+
+
+@pytest.mark.parametrize(
+    ('event_property', 'init_kwargs', 'input_df', 'exception', 'msg_substrings'),
+    [
+        pytest.param(
+            event_properties.duration,
+            {},
             pl.DataFrame(schema={'onset': pl.Int64}),
             pl.exceptions.ColumnNotFoundError,
             ('offset',),
-            id='missing_offset_column',
+            id='duration_missing_offset_column',
         ),
         pytest.param(
+            event_properties.duration,
+            {},
             pl.DataFrame(schema={'offset': pl.Int64}),
             pl.exceptions.ColumnNotFoundError,
             ('onset',),
-            id='missing_onset_column',
+            id='duration_missing_onset_column',
+        ),
+        pytest.param(
+            event_properties.peak_velocity,
+            {'velocity_columns': ('x_vel', 'y_vel')},
+            pl.DataFrame(schema={'x_vel': pl.Int64}),
+            pl.exceptions.ColumnNotFoundError,
+            ('y_vel',),
+            id='peak_velocity_missing_velocity_column',
+        ),
+        pytest.param(
+            event_properties.dispersion,
+            {'position_columns': ('x_pos', 'y_pos')},
+            pl.DataFrame(schema={'x_pos': pl.Int64}),
+            pl.exceptions.ColumnNotFoundError,
+            ('y_pos',),
+            id='dispersion_missing_position_column',
         ),
     ],
 )
-def test_duration_exceptions(input_df, exception, msg_substrings):
+def test_property_exceptions(event_property, init_kwargs, input_df, exception, msg_substrings):
+    property_expression = event_property(**init_kwargs)
     with pytest.raises(exception) as excinfo:
-        input_df.select([duration()])
+        input_df.select([property_expression])
 
     msg, = excinfo.value.args
     for msg_substring in msg_substrings:
@@ -53,19 +144,25 @@ def test_duration_exceptions(input_df, exception, msg_substrings):
 
 
 @pytest.mark.parametrize(
-    ('input_df', 'expected_df'),
+    ('event_property', 'init_kwargs', 'input_df', 'expected_df'),
     [
         pytest.param(
+            event_properties.duration,
+            {},
             pl.DataFrame(schema={'onset': pl.Int64, 'offset': pl.Int64}),
             pl.DataFrame(schema={'duration': pl.Int64}),
             id='empty_dataframe_results_in_empty_dataframe_with_correct_schema',
         ),
         pytest.param(
+            event_properties.duration,
+            {},
             pl.DataFrame({'onset': 0, 'offset': 1}, schema={'onset': pl.Int64, 'offset': pl.Int64}),
             pl.DataFrame({'duration': 1}, schema={'duration': pl.Int64}),
-            id='single_event_duration_1',
+            id='single_event_duration',
         ),
         pytest.param(
+            event_properties.duration,
+            {},
             pl.DataFrame(
                 {'onset': [0, 10], 'offset': [9, 23]},
                 schema={'onset': pl.Int64, 'offset': pl.Int64},
@@ -76,20 +173,47 @@ def test_duration_exceptions(input_df, exception, msg_substrings):
             ),
             id='two_events_different_durations',
         ),
+        pytest.param(
+            event_properties.peak_velocity,
+            {},
+            pl.DataFrame(
+                {'x_vel': [0, 0], 'y_vel': [1, 1]},
+                schema={'x_vel': pl.Float64, 'y_vel': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'peak_velocity': [1]},
+                schema={'peak_velocity': pl.Float64},
+            ),
+            id='single_event_peak_velocity',
+        ),
+        pytest.param(
+            event_properties.dispersion,
+            {},
+            pl.DataFrame(
+                {'x_pos': [0, 2], 'y_pos': [0, 3]},
+                schema={'x_pos': pl.Float64, 'y_pos': pl.Float64},
+            ),
+            pl.DataFrame(
+                {'dispersion': [5]},
+                schema={'dispersion': pl.Float64},
+            ),
+            id='single_event_dispersion',
+        ),
     ],
 )
-def test_duration_has_expected_result(input_df, expected_df):
-    result_df = input_df.select([duration()])
+def test_property_has_expected_result(event_property, init_kwargs, input_df, expected_df):
+    result_df = input_df.select([event_property(**init_kwargs).alias(event_property.__name__)])
 
     assert_frame_equal(result_df, expected_df)
 
 
 @pytest.mark.parametrize(
-    'property_function',
+    ('property_function', 'property_function_name'),
     [
-        pytest.param(duration),
+        pytest.param(event_properties.duration, 'duration', id='duration'),
+        pytest.param(event_properties.peak_velocity, 'peak_velocity', id='peak_velocity'),
     ],
 )
-def test_property_registered(property_function):
-    assert property_function.__name__ in PROPERTIES
-    assert PROPERTIES[property_function.__name__] == property_function
+def test_property_registered(property_function, property_function_name):
+    assert property_function_name in EVENT_PROPERTIES
+    assert EVENT_PROPERTIES[property_function_name] == property_function
diff --git a/tests/plotting/traceplot_test.py b/tests/plotting/traceplot_test.py
new file mode 100644
index 0000000..65cd6b2
--- /dev/null
+++ b/tests/plotting/traceplot_test.py
@@ -0,0 +1,125 @@
+# 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 traceplot."""
+from unittest.mock import Mock
+
+import matplotlib.colors
+import matplotlib.pyplot as plt
+import numpy as np
+import pytest
+from matplotlib import figure
+
+from pymovements.plotting import traceplot
+
+
+@pytest.fixture(name='args')
+def args_fixture():
+    x = np.arange(-100, 100)
+    y = np.arange(-100, 100)
+
+    return x, y
+
+
+@pytest.mark.parametrize(
+    'kwargs',
+    [
+        pytest.param({}, id='no_kwargs'),
+        pytest.param({'cval': np.arange(-100, 100)}, id='cval_array'),
+        pytest.param(
+            {'cval': np.arange(-100, 100), 'cmap_norm': 'twoslope'},
+            id='cmap_norm_twoslope',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'cmap_norm': 'nonorm'},
+            id='cmap_norm_nonorm',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200)},
+            id='cmap_norm_nonorm_implicit',
+        ),
+        pytest.param(
+            {'cval': np.arange(-100, 100), 'cmap_norm': 'normalize'},
+            id='cmap_norm_normalize',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'cmap_norm': 'linear'},
+            id='cmap_norm_linear',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'cmap_norm': matplotlib.colors.NoNorm()},
+            id='cmap_norm_class',
+        ),
+        pytest.param(
+            {'cmap': matplotlib.colors.LinearSegmentedColormap(name='test', segmentdata={})},
+            id='cmap_class',
+        ),
+        pytest.param({'cmap_segmentdata': {}}, id='cmap_segmentdata'),
+        pytest.param({'padding': 0.1}, id='padding'),
+        pytest.param({'cval': np.arange(0, 200), 'show_cbar': True}, id='show_cbar_true'),
+        pytest.param({'cval': np.arange(0, 200), 'show_cbar': False}, id='show_cbar_false'),
+    ],
+)
+def test_traceplot_show(args, kwargs, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    traceplot(*args, **kwargs)
+    mock.assert_called_once()
+
+
+def test_traceplot_noshow(args, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    traceplot(*args, show=False)
+    mock.assert_not_called()
+
+
+def test_traceplot_save(args, monkeypatch, tmp_path):
+    mock = Mock()
+    monkeypatch.setattr(figure.Figure, 'savefig', mock)
+    traceplot(*args, show=False, savepath=str(tmp_path / 'test.svg'))
+    mock.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'exception'),
+    [
+        pytest.param(
+            {'x': np.arange(100), 'y': np.arange(11)},
+            ValueError,
+            id='different_lengths',
+        ),
+        pytest.param(
+            {
+                'x': np.arange(-100, 100),
+                'y': np.arange(-100, 100),
+                'cval': np.arange(0, 200),
+                'cmap_norm': 'invalid',
+            },
+            ValueError,
+            id='cmap_norm_unsupported',
+        ),
+    ],
+)
+def test_traceplot_exceptions(kwargs, exception, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+
+    with pytest.raises(exception):
+        traceplot(**kwargs)
diff --git a/tests/plotting/tsplot_test.py b/tests/plotting/tsplot_test.py
new file mode 100644
index 0000000..fac81a9
--- /dev/null
+++ b/tests/plotting/tsplot_test.py
@@ -0,0 +1,96 @@
+# 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 tsplot."""
+from unittest.mock import Mock
+
+import matplotlib.pyplot as plt
+import numpy as np
+import pytest
+from matplotlib import figure
+
+from pymovements.plotting import tsplot
+
+
+@pytest.fixture(name='arr')
+def arr_fixture():
+    x = np.arange(-100, 100)
+    y = np.arange(-100, 100)
+    arr = np.column_stack((x, y)).transpose()
+
+    return arr
+
+
+@pytest.mark.parametrize(
+    'kwargs',
+    [
+        pytest.param({}, id='no_kwargs'),
+        pytest.param({'share_y': False}, id='share_y_false'),
+        pytest.param({'show_yticks': False}, id='show_yticks_false'),
+        pytest.param({'channel_names': ['foo', 'bar']}, id='channel_names'),
+        pytest.param(
+            {'channel_names': ['foo', 'bar'], 'rotate_ylabels': False},
+            id='channel_names_no_rotate',
+        ),
+    ],
+)
+def test_tsplot_show(arr, kwargs, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    tsplot(arr, **kwargs)
+    mock.assert_called_once()
+
+
+def test_tsplot_1d(monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    tsplot(np.arange(-100, 100))
+    mock.assert_called_once()
+
+
+def test_tsplot_noshow(arr, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    tsplot(arr, show=False)
+    mock.assert_not_called()
+
+
+def test_tsplot_save(arr, monkeypatch, tmp_path):
+    mock = Mock()
+    monkeypatch.setattr(figure.Figure, 'savefig', mock)
+    tsplot(arr, show=False, savepath=str(tmp_path / 'test.svg'))
+    mock.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'exception'),
+    [
+        pytest.param(
+            {'arr': np.ones((1000, 3, 3))},
+            ValueError,
+            id='3_dim_input',
+        ),
+    ],
+)
+def test_tsplot_exceptions(kwargs, exception, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+
+    with pytest.raises(exception):
+        tsplot(**kwargs)