Repository: aeye-lab/pymovements
Pull request #673 (2024-02-08 14:52:05)

feat: introduce measure_samples(), MeasureLibrary and null_ratio
## Description

This introduces a first measure using the `GazeDataFrame.measure_samples()` method. The measures calculation is grouped by trial columns.

## Implemented changes

- [x] `GazeDataFrame.measure_samples()`
- [x] `MeasureLibrary` 
- [x] `null_ratio` measure

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes.
Provide instructions so we can reproduce.
Please also list any relevant details for your test configuration

- [x] tested `null_ratio()` with `None` and `nan`
- [x] tested if `MeasureLibrary` has registered `null_ratio`
- [x] tested `GazeDataFrame.get_measure()` with and without groupby

## 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


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Attention: `14 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`e6e81a8`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/e6e81a8eaed72f045aeae3b906978c8a35050618?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`ef940db`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 42.67%.

| [Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Patch % | Lines |
|---|---|---|
| [src/pymovements/gaze/gaze\_dataframe.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZ2F6ZV9kYXRhZnJhbWUucHk=) | 14.28% | [6 Missing :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |
| [src/pymovements/measure/measures.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL21lYXN1cmUvbWVhc3VyZXMucHk=) | 50.00% | [6 Missing :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |
| [src/pymovements/measure/library.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL21lYXN1cmUvbGlicmFyeS5weQ==) | 90.00% | [2 Missing :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |

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


```diff
@@             Coverage Diff              @@
##              main     #673       +/-   ##
============================================
- Coverage   100.00%   42.67%   -57.33%     
============================================
  Files           54       57        +3     
  Lines         2423     2470       +47     
  Branches       614      623        +9     
============================================
- Hits          2423     1054     -1369     
- Misses           0     1369     +1369     
- Partials         0       47       +47     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/673?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).


Patch:
diff --git a/src/pymovements/__init__.py b/src/pymovements/__init__.py
index feec64b..7e83f22 100644
--- a/src/pymovements/__init__.py
+++ b/src/pymovements/__init__.py
@@ -23,6 +23,7 @@ from pymovements import datasets
 from pymovements import events
 from pymovements import exceptions
 from pymovements import gaze
+from pymovements import measure
 from pymovements import plotting
 from pymovements import stimulus
 from pymovements import synthetic
@@ -38,6 +39,8 @@ from pymovements.events import EventProcessor
 from pymovements.gaze import Experiment
 from pymovements.gaze import GazeDataFrame
 from pymovements.gaze import Screen
+from pymovements.measure import register_sample_measure
+from pymovements.measure import SampleMeasureLibrary
 from pymovements.stimulus import text
 
 
@@ -60,6 +63,11 @@ __all__ = [
     'GazeDataFrame',
 
     'exceptions',
+
+    'measure',
+    'SampleMeasureLibrary',
+    'register_sample_measure',
+
     'plotting',
     'stimulus',
     'synthetic',
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index dfdf025..de685c1 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -195,6 +195,9 @@ class GazeDataFrame:
             data = data.clone()
         self.frame = data
 
+        # Set nan values to null.
+        self.frame = self.frame.fill_nan(None)
+
         self.trial_columns = [trial_columns] if isinstance(trial_columns, str) else trial_columns
         self.experiment = experiment
 
@@ -716,6 +719,69 @@ class GazeDataFrame:
                 how='diagonal',
             )
 
+    def measure_samples(
+            self,
+            method: str | Callable[..., pl.Expr],
+            **kwargs: Any,
+    ) -> pl.DataFrame:
+        """Calculate eye movement measure for gaze data samples.
+
+        If :py:class:``GazeDataFrame`` has :py:attr:``trial_columns``, measures will be grouped by
+        trials.
+
+        Parameters
+        ----------
+        method: str | Callable[..., pl.Expr]
+            Measure to be calculated.
+        **kwargs: Any
+            Keyword arguments to be passed to the respective measure function.
+
+        Returns
+        -------
+        pl.DataFrame
+            Measure results.
+
+        Examples
+        --------
+        Let's initialize an example GazeDataFrame first:
+        >>> gaze = pm.gaze.from_numpy(
+        ...     distance=np.concatenate([np.zeros(40), np.full(10, np.nan), np.ones(50)]),
+        ... )
+
+        You can calculate measures, for example the null ratio like this:
+        >>> gaze.measure_samples('null_ratio', column='distance')
+        shape: (1, 1)
+        ┌────────────┐
+        │ null_ratio │
+        │ ---        │
+        │ f64        │
+        ╞════════════╡
+        │ 0.1        │
+        └────────────┘
+        """
+        if isinstance(method, str):
+            method = pm.measure.SampleMeasureLibrary.get(method)
+
+        if 'column_dtype' in inspect.getfullargspec(method).args:
+            kwargs['column_dtype'] = self.frame[kwargs['column']].dtype
+
+        if self.trial_columns is None:
+            return self.frame.select(method(**kwargs))
+
+        # Group measure values by trial columns.
+        return pl.concat(
+            [
+                df.select(
+                    [  # add trial columns first, then add column for measure.
+                        pl.lit(value).cast(self.frame.schema[name]).alias(name)
+                        for name, value in zip(self.trial_columns, trial_values)
+                    ] + [method(**kwargs)],
+                )
+                for trial_values, df in
+                self.frame.group_by(self.trial_columns, maintain_order=True)
+            ],
+        )
+
     @property
     def schema(self) -> pl.type_aliases.SchemaDict:
         """Schema of event dataframe."""
diff --git a/src/pymovements/measure/__init__.py b/src/pymovements/measure/__init__.py
new file mode 100644
index 0000000..3b653c0
--- /dev/null
+++ b/src/pymovements/measure/__init__.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2024 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.
+"""Provides eye movement measures."""
+from pymovements.measure.library import register_sample_measure
+from pymovements.measure.library import SampleMeasureLibrary
+from pymovements.measure.measures import null_ratio
+
+__all__ = [
+    'SampleMeasureLibrary',
+    'register_sample_measure',
+
+    'null_ratio',
+]
diff --git a/src/pymovements/measure/library.py b/src/pymovements/measure/library.py
new file mode 100644
index 0000000..c39401f
--- /dev/null
+++ b/src/pymovements/measure/library.py
@@ -0,0 +1,101 @@
+# Copyright (c) 2022-2024 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 the measure library."""
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import TypeVar
+
+import polars as pl
+
+
+class SampleMeasureLibrary:
+    """Provides access by name to sample measure methods.
+
+    Attributes
+    ----------
+    methods: dict[str, Callable[..., pl.Expr]]
+        Dictionary of measure methods.
+    """
+
+    methods: dict[str, Callable[..., pl.Expr]] = {}
+
+    @classmethod
+    def add(cls, method: Callable[..., pl.Expr]) -> None:
+        """Add a measure method to the library.
+
+        Parameters
+        ----------
+        method: Callable[..., pl.Expr]
+            The measure method to add to the library.
+        """
+        cls.methods[method.__name__] = method
+
+    @classmethod
+    def get(cls, name: str) -> Callable[..., pl.Expr]:
+        """Get measure method py name.
+
+        Parameters
+        ----------
+        name: str
+            Name of the measure method in the library.
+
+        Returns
+        -------
+        Callable[..., pl.Expr]
+            The requested measure method.
+        """
+        return cls.methods[name]
+
+    @classmethod
+    def __contains__(cls, name: str) -> bool:
+        """Check if class contains method of given name.
+
+        Parameters
+        ----------
+        name: str
+            Name of the method to check.
+
+        Returns
+        -------
+        bool
+            True if MeasureLibrary contains method with given name, else False.
+        """
+        return name in cls.methods
+
+
+SampleMeasureMethod = TypeVar('SampleMeasureMethod', bound=Callable[..., pl.Expr])
+
+
+def register_sample_measure(method: SampleMeasureMethod) -> SampleMeasureMethod:
+    """Register a sample measure method.
+
+    Parameters
+    ----------
+    method: SampleMeasureMethod
+        The measure method to register.
+
+    Returns
+    -------
+    SampleMeasureMethod
+        The registered sample measure method.
+    """
+    SampleMeasureLibrary.add(method)
+    return method
diff --git a/src/pymovements/measure/measures.py b/src/pymovements/measure/measures.py
new file mode 100644
index 0000000..1eb0dc2
--- /dev/null
+++ b/src/pymovements/measure/measures.py
@@ -0,0 +1,60 @@
+# Copyright (c) 2024 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.
+"""Provides eye movement measure implementations."""
+from __future__ import annotations
+
+import polars as pl
+
+from pymovements.measure.library import register_sample_measure
+
+
+@register_sample_measure
+def null_ratio(column: str, column_dtype: pl.DataType) -> pl.Expr:
+    """Ratio of null values to overall values.
+
+    In case of list columns, a null element in the list will count as overall null for the
+    respective cell.
+
+    Parameters
+    ----------
+    column: str
+        Name of measured column.
+    column_dtype: pl.DataType
+        Data type of measured column.
+
+    Returns
+    -------
+    pl.Expr
+        Null ratio expression.
+    """
+    if column_dtype in {pl.Float64, pl.Int64}:
+        value = 1 - pl.col(column).fill_nan(pl.lit(None)).count() / pl.col(column).len()
+    elif column_dtype == pl.Utf8:
+        value = 1 - pl.col(column).count() / pl.col(column).len()
+    elif column_dtype == pl.List:
+        non_null_lengths = pl.col(column).list.drop_nulls().drop_nans().list.len()
+        value = 1 - (non_null_lengths == pl.col(column).list.len()).sum() / pl.col(column).len()
+    else:
+        raise TypeError(
+            'column_dtype must be of type {Float64, Int64, Utf8, List}'
+            f' but is of type {column_dtype}',
+        )
+
+    return value.alias('null_ratio')


Test patch:
diff --git a/tests/unit/dataset/dataset_files_test.py b/tests/unit/dataset/dataset_files_test.py
index 2b0e231..e7f2d21 100644
--- a/tests/unit/dataset/dataset_files_test.py
+++ b/tests/unit/dataset/dataset_files_test.py
@@ -20,7 +20,6 @@
 """Tests pymovements asc to csv processing."""
 # flake8: noqa: E101, W191, E501
 # pylint: disable=duplicate-code
-import numpy as np
 import polars as pl
 import pytest
 from polars.testing import assert_frame_equal
@@ -155,7 +154,7 @@ MSG	2154570 0 READING_SCREEN_1.STOP
 EXPECTED_DF_NO_PATTERNS = pl.from_dict(
     {
         'time': [2154557, 2154558, 2154560, 2154561, 2154565, 2154567, 2154568],
-        'pixel': [(139.6, 132.1), (139.5, 131.9), (np.nan, np.nan), (850.7, 717.5), (139.5, 131.9), (np.nan, np.nan), (850.7, 717.5)],
+        'pixel': [(139.6, 132.1), (139.5, 131.9), (None, None), (850.7, 717.5), (139.5, 131.9), (None, None), (850.7, 717.5)],
         'pupil': [784.0, 784.0, 0.0, 714.0, 784.0, 0.0, 714.0],
     },
 )
@@ -163,7 +162,7 @@ EXPECTED_DF_NO_PATTERNS = pl.from_dict(
 EXPECTED_DF_PATTERNS = pl.from_dict(
     {
         'time': [2154557, 2154558, 2154560, 2154561, 2154565, 2154567, 2154568],
-        'pixel': [(139.6, 132.1), (139.5, 131.9), (np.nan, np.nan), (850.7, 717.5), (139.5, 131.9), (np.nan, np.nan), (850.7, 717.5)],
+        'pixel': [(139.6, 132.1), (139.5, 131.9), (None, None), (850.7, 717.5), (139.5, 131.9), (None, None), (850.7, 717.5)],
         'pupil': [784.0, 784.0, 0.0, 714.0, 784.0, 0.0, 714.0],
         'task': ['reading', 'reading', 'reading', 'reading', 'reading', 'reading', 'reading'],
         'trial_id': [0, 0, 0, 0, 1, 1, 1],
diff --git a/tests/unit/gaze/measure_test.py b/tests/unit/gaze/measure_test.py
new file mode 100644
index 0000000..b38185f
--- /dev/null
+++ b/tests/unit/gaze/measure_test.py
@@ -0,0 +1,271 @@
+# Copyright (c) 2023-2024 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 GazeDataFrame measure_samples method."""
+import numpy as np
+import polars as pl
+import pytest
+from polars.testing import assert_frame_equal
+
+import pymovements as pm
+
+
+def my_test_measure(column: str) -> pl.Expr:
+    return pl.col(column).len().cast(pl.Int64).alias('my_measure')
+
+
+@pytest.mark.parametrize(
+    ('gaze', 'method', 'kwargs', 'expected'),
+    [
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [1000, 1001, 1002, 1003]}, schema={'A': pl.Int64}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [0.0]}),
+            id='null_ratio_int_column_no_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [1000, None, None, 1003]}, schema={'A': pl.Int64}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_int_column_half_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [1.0, np.nan, np.nan, 1.3]}, schema={'A': pl.Float64}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_int_column_half_nans',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [None, None, None, None]}, schema={'A': pl.Int64}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [1.0]}),
+            id='null_ratio_int_column_all_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [0.0, 0.1, 0.2, 0.3]}, schema={'A': pl.Float64}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [0.0]}),
+            id='null_ratio_float_column_no_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [0.0, None, None, 0.3]}, schema={'A': pl.Float64}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_float_column_half_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [None, None, None, None]}, schema={'A': pl.Float64}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [1.0]}),
+            id='null_ratio_float_column_all_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': ['a', 'b', 'c', 'd']}, schema={'A': pl.Utf8}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [0.0]}),
+            id='null_ratio_str_column_no_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': ['a', None, None, 'd']}, schema={'A': pl.Utf8}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_str_column_half_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [None, None, None, None]}, schema={'A': pl.Utf8}),
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'null_ratio': [1.0]}),
+            id='null_ratio_str_column_all_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    data={'t': [1000, 1001, 1002], 'x': [0.1, 0.2, 0.3], 'y': [0.1, 0.2, 0.3]},
+                ),
+                time_column='t',
+                pixel_columns=['x', 'y'],
+            ),
+            'null_ratio',
+            {'column': 'pixel'},
+            pl.DataFrame(data={'null_ratio': [0.0]}),
+            id='null_ratio_pixel_no_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    data={
+                        't': [1000, 1001, 1002], 'x': [None, None, None], 'y': [None, None, None],
+                    },
+                ),
+                time_column='t',
+                pixel_columns=['x', 'y'],
+            ),
+            'null_ratio',
+            {'column': 'pixel'},
+            pl.DataFrame(data={'null_ratio': [1.0]}),
+            id='null_ratio_pixel_all_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    data={
+                        't': [1000, 1001], 'x': [0.1, None], 'y': [0.2, None],
+                    },
+                ),
+                time_column='t',
+                pixel_columns=['x', 'y'],
+            ),
+            'null_ratio',
+            {'column': 'pixel'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_pixel_half_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.DataFrame(
+                    data={
+                        't': [1000, 1001], 'x': [0.1, np.nan], 'y': [0.2, np.nan],
+                    },
+                ),
+                time_column='t',
+                pixel_columns=['x', 'y'],
+            ),
+            'null_ratio',
+            {'column': 'pixel'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_pixel_half_nans',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    data={
+                        't': [1000, 1001], 'x': [0.1, None], 'y': [0.2, None],
+                    },
+                ),
+                time_column='t',
+                position_columns=['x', 'y'],
+            ),
+            'null_ratio',
+            {'column': 'position'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_position_half_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    data={
+                        't': [1000, 1001], 'x': [0.1, None], 'y': [0.2, None],
+                    },
+                ),
+                time_column='t',
+                velocity_columns=['x', 'y'],
+            ),
+            'null_ratio',
+            {'column': 'velocity'},
+            pl.DataFrame(data={'null_ratio': [0.5]}),
+            id='null_ratio_velocity_half_nulls',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    data={'A': [1000, 1001, 1002, 1003], 'trial': [1, 1, 1, 1]},
+                    schema={'A': pl.Int64, 'trial': pl.Int64},
+                ),
+                trial_columns='trial',
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'trial': [1], 'null_ratio': [0.0]}),
+            id='null_ratio_int_column_no_nulls_single_trial',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(
+                    data={'A': [1000, 1001, None, None], 'trial': [1, 1, 2, 2]},
+                    schema={'A': pl.Int64, 'trial': pl.Int64},
+                ),
+                trial_columns='trial',
+            ),
+            'null_ratio',
+            {'column': 'A'},
+            pl.DataFrame(data={'trial': [1, 2], 'null_ratio': [0.0, 1.0]}),
+            id='null_ratio_int_column_no_nulls_two_trials',
+        ),
+
+        pytest.param(
+            pm.GazeDataFrame(
+                data=pl.from_dict(data={'A': [1000, 1001, 1002, 1003]}, schema={'A': pl.Int64}),
+            ),
+            my_test_measure,
+            {'column': 'A'},
+            pl.DataFrame(data={'my_measure': [4]}),
+            id='null_ratio_int_column_no_nulls',
+        ),
+    ],
+)
+def test_measure_samples(gaze, method, kwargs, expected):
+    df = gaze.measure_samples(method, **kwargs)
+    assert_frame_equal(df, expected)
diff --git a/tests/unit/measure/measure_library_test.py b/tests/unit/measure/measure_library_test.py
new file mode 100644
index 0000000..6db92cc
--- /dev/null
+++ b/tests/unit/measure/measure_library_test.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2023-2024 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 measure library."""
+from __future__ import annotations
+
+import pytest
+
+import pymovements as pm
+
+
+@pytest.mark.parametrize(
+    ('method', 'name'),
+    [
+        pytest.param(pm.measure.null_ratio, 'null_ratio', id='null_ratio'),
+    ],
+)
+def test_measure_registered(method, name):
+    assert name in pm.measure.SampleMeasureLibrary()
+    assert pm.measure.SampleMeasureLibrary.get(name) == method
+    assert pm.measure.SampleMeasureLibrary.get(name).__name__ == name
diff --git a/tests/unit/measure/null_ratio_test.py b/tests/unit/measure/null_ratio_test.py
new file mode 100644
index 0000000..f1c52cf
--- /dev/null
+++ b/tests/unit/measure/null_ratio_test.py
@@ -0,0 +1,145 @@
+# Copyright (c) 2023-2024 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 null ratio measure."""
+import polars as pl
+import pytest
+from polars.testing import assert_frame_equal
+
+import pymovements as pm
+
+
+@pytest.mark.parametrize(
+    ('df', 'kwargs', 'expected'),
+    [
+        pytest.param(
+            pl.from_dict(data={'A': [0.1, 0.2, 0.3, 0.4]}, schema={'A': pl.Float64}),
+            {'column': 'A', 'column_dtype': pl.Float64},
+            pl.from_dict(data={'null_ratio': [0.0]}),
+            id='float_dtype_4_rows_no_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [0.1, None, 0.3, None]}, schema={'A': pl.Float64}),
+            {'column': 'A', 'column_dtype': pl.Float64},
+            pl.from_dict(data={'null_ratio': [0.5]}),
+            id='float_dtype_4_rows_half_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [None, None, None, None]}, schema={'A': pl.Float64}),
+            {'column': 'A', 'column_dtype': pl.Float64},
+            pl.from_dict(data={'null_ratio': [1.0]}),
+            id='float_dtype_4_rows_all_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [1, 2, 3, 4]}, schema={'A': pl.Int64}),
+            {'column': 'A', 'column_dtype': pl.Int64},
+            pl.from_dict(data={'null_ratio': [0.0]}),
+            id='int_dtype_4_rows_no_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [1, None, 3, None]}, schema={'A': pl.Int64}),
+            {'column': 'A', 'column_dtype': pl.Int64},
+            pl.from_dict(data={'null_ratio': [0.5]}),
+            id='int_dtype_4_rows_half_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [None, None, None, None]}, schema={'A': pl.Int64}),
+            {'column': 'A', 'column_dtype': pl.Int64},
+            pl.from_dict(data={'null_ratio': [1.0]}),
+            id='int_dtype_4_rows_all_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [None, None, None, None]}, schema={'A': pl.Utf8}),
+            {'column': 'A', 'column_dtype': pl.Utf8},
+            pl.from_dict(data={'null_ratio': [1.0]}),
+            id='str_dtype_4_rows_all_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': ['1', '2', '3', '4']}, schema={'A': pl.Utf8}),
+            {'column': 'A', 'column_dtype': pl.Utf8},
+            pl.from_dict(data={'null_ratio': [0.0]}),
+            id='str_dtype_4_rows_no_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': ['1', None, '3', None]}, schema={'A': pl.Utf8}),
+            {'column': 'A', 'column_dtype': pl.Utf8},
+            pl.from_dict(data={'null_ratio': [0.5]}),
+            id='str_dtype_4_rows_half_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [[0.1, 0.2], [0.3, 0.4]]}),
+            {'column': 'A', 'column_dtype': pl.List(pl.Float64)},
+            pl.from_dict(data={'null_ratio': [0.0]}),
+            id='list_dtype_2_elem_2_rows_no_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [[None, None], [None, None]]}),
+            {'column': 'A', 'column_dtype': pl.List(pl.Float64)},
+            pl.from_dict(data={'null_ratio': [1.0]}),
+            id='list_dtype_2_elem_2_rows_all_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [[0.1, 0.2], [None, None]]}),
+            {'column': 'A', 'column_dtype': pl.List(pl.Float64)},
+            pl.from_dict(data={'null_ratio': [0.5]}),
+            id='list_dtype_2_elem_2_rows_half_nulls',
+        ),
+
+        pytest.param(
+            pl.from_dict(data={'A': [[0.1, None], [0.2, None]]}),
+            {'column': 'A', 'column_dtype': pl.List(pl.Float64)},
+            pl.from_dict(data={'null_ratio': [1.0]}),
+            id='list_dtype_2_elem_2_rows_half_nulls_each_row',
+        ),
+    ],
+)
+def test_null_ratio_expected(df, kwargs, expected):
+    result = df.select(pm.measure.null_ratio(**kwargs))
+    assert_frame_equal(result, expected)
+
+
+@pytest.mark.parametrize(
+    ('df', 'kwargs', 'exception', 'message'),
+    [
+        pytest.param(
+            pl.DataFrame({'A': [1, 2], 'B': [True, False]}).select(pl.struct(pl.all()).alias('C')),
+            {'column': 'C', 'column_dtype': pl.Struct},
+            TypeError,
+            'column_dtype must be of type {Float64, Int64, Utf8, List} but is of type Struct',
+            id='struct_column',
+        ),
+    ],
+)
+def test_null_ratio_raises(df, kwargs, exception, message):
+    with pytest.raises(exception) as excinfo:
+        df.select(pm.measure.null_ratio(**kwargs))
+
+    exception_message, = excinfo.value.args
+    assert exception_message == message