Repository: aeye-lab/pymovements
Pull request #617 (2023-10-26 10:38:28)

feat: add EyeTracker class
## Description

Added an EyeTracker class that can keep parsed eyetracker related data.

Fixes issue #597 

## Implemented changes

- [x] Create class EyeTracker
- [x] Use class in Experiment
- [x] Add short-access to `Experiment.eye_tracker.sampling_rate` from `Experiment`

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change is or requires a documentation update

## How Has This Been Tested?

- [x] Test class `tests.unit.gaze.eyetracker_test.py`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] 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


SiQube: #618 fixes the docs problems

Patch:
diff --git a/src/pymovements/datasets/toy_dataset_eyelink.py b/src/pymovements/datasets/toy_dataset_eyelink.py
index 20e4f59..265a451 100644
--- a/src/pymovements/datasets/toy_dataset_eyelink.py
+++ b/src/pymovements/datasets/toy_dataset_eyelink.py
@@ -29,6 +29,7 @@ import polars as pl
 from pymovements.dataset.dataset_definition import DatasetDefinition
 from pymovements.dataset.dataset_library import register_dataset
 from pymovements.gaze.experiment import Experiment
+from pymovements.gaze.eyetracker import EyeTracker
 
 
 @dataclass
@@ -115,7 +116,13 @@ class ToyDatasetEyeLink(DatasetDefinition):
         screen_height_cm=30.2,
         distance_cm=68,
         origin='lower left',
-        sampling_rate=1000,
+        eyetracker=EyeTracker(
+            sampling_rate=1000.0,
+            left=False,
+            right=True,
+            model='EyeLink Portable Duo',
+            vendor='EyeLink',
+        ),
     )
 
     filename_format: str = r'subject_{subject_id:d}_session_{session_id:d}.asc'
diff --git a/src/pymovements/gaze/experiment.py b/src/pymovements/gaze/experiment.py
index 5fafd3b..40ee86d 100644
--- a/src/pymovements/gaze/experiment.py
+++ b/src/pymovements/gaze/experiment.py
@@ -20,15 +20,16 @@
 """Provides the Experiment class."""
 from __future__ import annotations
 
+from typing import Any
+
 import numpy as np
 
 from pymovements.gaze import transforms_numpy
+from pymovements.gaze.eyetracker import EyeTracker
 from pymovements.gaze.screen import Screen
 from pymovements.utils import checks
-from pymovements.utils import decorators
 
 
-@decorators.auto_str
 class Experiment:
     """Experiment class for holding experiment properties.
 
@@ -51,6 +52,8 @@ class Experiment:
         (default: 'lower left')
     sampling_rate: float | None
         Sampling rate in Hz. (default: None)
+    eyetracker : EyeTracker | None
+        EyeTracker object for experiment. (default: None)
 
     Examples
     --------
@@ -64,8 +67,8 @@ class Experiment:
     ...     sampling_rate=1000.0,
     ... )
     >>> print(experiment)
-    Experiment(screen=Screen(width_px=1280, height_px=1024, width_cm=38,
-    height_cm=30, distance_cm=68, origin=lower left), sampling_rate=1000.00)
+    Experiment(sampling_rate=1000.00, screen=Screen(width_px=1280, height_px=1024, width_cm=38,
+    height_cm=30, distance_cm=68, origin=lower left), eyetracker=None)
 
     We can also access the screen boundaries in degrees of visual angle via the
     :py:attr:`~pymovements.gaze.Screen` object. This only works if the
@@ -85,8 +88,8 @@ class Experiment:
     ----------
     screen: Screen
         Screen object for experiment
-    sampling_rate: float
-        Sampling rate in Hz
+    eyetracker : EyeTracker | None
+        Eye tracker for experiment
     """
 
     def __init__(
@@ -98,6 +101,7 @@ class Experiment:
             distance_cm: float | None = None,
             origin: str = 'lower left',
             sampling_rate: float | None = None,
+            eyetracker: EyeTracker | None = None,
     ):
         self.screen = Screen(
             width_px=screen_width_px,
@@ -108,12 +112,31 @@ class Experiment:
             origin=origin,
         )
 
-        checks.check_is_not_none(sampling_rate=sampling_rate)
-        assert sampling_rate is not None
+        checks.check_is_mutual_exclusive(sampling_rate=sampling_rate, eyetracker=eyetracker)
+
+        self.eyetracker = eyetracker
+
+        self._sampling_rate = sampling_rate
+
+        checks.check_is_not_none(sampling_rate=self.sampling_rate)
+        assert self.sampling_rate is not None
+
+        checks.check_is_greater_than_zero(sampling_rate=self.sampling_rate)
+
+    @property
+    def sampling_rate(self) -> float | None:
+        """Get sampling rate of experiment."""
+        if self._sampling_rate is not None:
+            return self._sampling_rate
 
-        checks.check_is_greater_than_zero(sampling_rate=sampling_rate)
+        assert self.eyetracker is not None
 
-        self.sampling_rate = sampling_rate
+        return self.eyetracker.sampling_rate
+
+    @sampling_rate.setter
+    def sampling_rate(self, sampling_rate: float | None = None) -> None:
+        """Set sampling rate of experiment."""
+        self._sampling_rate = sampling_rate
 
     def pos2vel(
             self,
@@ -169,6 +192,22 @@ class Experiment:
                [1000., 1000.],
                [ 500.,  500.]])
         """
+        assert self.sampling_rate is not None
         return transforms_numpy.pos2vel(
             arr=arr, sampling_rate=self.sampling_rate, method=method, **kwargs,
         )
+
+    def __str__(self: Any) -> str:
+        """Print experiment."""
+
+        def shorten(value: Any) -> str:
+            if isinstance(value, float):
+                value = f'{value:.2f}'
+            return value
+
+        attributes = ''
+        for key, value in vars(self).items():
+            if not key.startswith('_'):
+                attributes += ', ' + f'{key}={shorten(value)}'
+
+        return f'{type(self).__name__}(sampling_rate={shorten(self.sampling_rate)}{attributes})'
diff --git a/src/pymovements/gaze/eyetracker.py b/src/pymovements/gaze/eyetracker.py
new file mode 100644
index 0000000..0c7e07a
--- /dev/null
+++ b/src/pymovements/gaze/eyetracker.py
@@ -0,0 +1,77 @@
+# 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.
+"""Holds the EyeTracker class."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pymovements.utils import checks
+
+
+@dataclass
+class EyeTracker:
+    """EyeTracker class for holding eyetracker properties.
+
+    Attributes
+    ----------
+    sampling_rate : float | None
+        Sample rate of recording (in Hz). (default: None)
+    left : bool | None
+        Whether the left eye is tracked. (default: None)
+    right : bool | None
+        Whether the right eye is tracked. (default: None)
+    model : str | None
+        Eye tracker model (e.g. 'EyeLink II', 'Tobii Pro Spectrum'). (default: None)
+    version : str | None
+        Eye tracker software version number. (default: None)
+    vendor : str | None
+        Eye tracker vendor (e.g. 'EyeLink', 'Tobii'). (default: None)
+    mount : str | None
+        The mounting setup of the eye tracker (e.g. 'Desktop / Monocular / Remote').
+        (default: None)
+
+    Examples
+    --------
+        >>> eyetracker = EyeTracker(
+        ...     sampling_rate = 1000.0,
+        ...     left = False,
+        ...     right = True,
+        ...     model = 'EyeLink 1000 Plus',
+        ...     version = '1.5.3',
+        ...     vendor = 'EyeLink',
+        ...     mount = 'Arm Mount / Monocular / Remote',
+        ... )
+        >>> print(eyetracker)
+        EyeTracker(sampling_rate=1000.0, left=False, right=True, model='EyeLink 1000 Plus',
+        version='1.5.3', vendor='EyeLink', mount='Arm Mount / Monocular / Remote')
+    """
+
+    sampling_rate: float | None = None
+    left: bool | None = None
+    right: bool | None = None
+    model: str | None = None
+    version: str | None = None
+    vendor: str | None = None
+    mount: str | None = None
+
+    def __post_init__(self) -> None:
+        """Check that the sampling rate is a positive number."""
+        if self.sampling_rate is not None:
+            checks.check_is_greater_than_zero(sampling_rate=self.sampling_rate)
diff --git a/src/pymovements/plotting/heatmap.py b/src/pymovements/plotting/heatmap.py
index 8ead2fa..dc2ab68 100644
--- a/src/pymovements/plotting/heatmap.py
+++ b/src/pymovements/plotting/heatmap.py
@@ -104,6 +104,8 @@ def heatmap(
             'GazeDataFrame must be associated with an experiment.',
         )
 
+    assert gaze.experiment.sampling_rate is not None
+
     # Get experiment screen properties
     screen = gaze.experiment.screen
 


Test patch:
diff --git a/tests/unit/gaze/experiment_test.py b/tests/unit/gaze/experiment_test.py
new file mode 100644
index 0000000..58ba33e
--- /dev/null
+++ b/tests/unit/gaze/experiment_test.py
@@ -0,0 +1,38 @@
+# 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.
+"""Test for Experiment class."""
+import pytest
+
+from pymovements.gaze.experiment import Experiment
+from pymovements.gaze.eyetracker import EyeTracker
+
+
+def test_sampling_rate_setter():
+    experiment = Experiment(1280, 1024, 38, 30, sampling_rate=1000.0)
+    assert experiment.sampling_rate == 1000.0
+
+    experiment.sampling_rate = 100.0
+    assert experiment.sampling_rate == 100.0
+
+
+def test_sampling_rate_invalid():
+    with pytest.raises(TypeError):
+        eyetracker = EyeTracker()
+        Experiment(1280, 1024, 38, 30, eyetracker=eyetracker)
diff --git a/tests/unit/gaze/eyetracker_test.py b/tests/unit/gaze/eyetracker_test.py
new file mode 100644
index 0000000..921e9bb
--- /dev/null
+++ b/tests/unit/gaze/eyetracker_test.py
@@ -0,0 +1,45 @@
+# 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 for EyeTracker class."""
+import pytest
+
+from pymovements.gaze.eyetracker import EyeTracker
+
+
+def test_eyetracker_with_positive_sampling_rate():
+    EyeTracker(
+        1000.0, False, True, 'EyeLink 1000 Plus',
+        '1.5.3', 'Arm Mount / Monocular / Remote',
+    )
+
+
+def test_eyetracker_with_negative_sampling_rate():
+    with pytest.raises(ValueError):
+        EyeTracker(
+            -500.0, False, True, 'EyeLink 1000 Plus',
+            '1.5.3', 'Arm Mount / Monocular / Remote',
+        )
+
+
+def test_eyetracker_without_sampling_rate():
+    EyeTracker(
+        None, False, True, 'EyeLink 1000 Plus',
+        '1.5.3', 'Arm Mount / Monocular / Remote',
+    )