Repository: aeye-lab/pymovements
Pull request #721 (2024-04-22 11:16:16)

feat: make all arguments in screen and experiment optional
## Description

Removes artificial restriction and allows for all experiment arguments to be `None`.

Fixes issue #717

## Implemented changes

- [x] all Screen attributes are optional
- [x] all Experiment attributes are optional

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## 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/721?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Attention: Patch coverage is `77.41935%` with `14 lines` in your changes are missing coverage. Please review.
> Project coverage is 99.43%. Comparing base [(`115a9b3`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/115a9b3febc4796e8952661a39cd6926de4ebb63?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`b435768`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/721?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

| [Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/721?dropdown=coverage&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/screen.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/721?src=pr&el=tree&filepath=src%2Fpymovements%2Fgaze%2Fscreen.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvc2NyZWVuLnB5) | 75.86% | [5 Missing and 9 partials :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/721?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     #721      +/-   ##
===========================================
- Coverage   100.00%   99.43%   -0.57%     
===========================================
  Files           60       60              
  Lines         2757     2814      +57     
  Branches       709      718       +9     
===========================================
+ Hits          2757     2798      +41     
- Misses           0        6       +6     
- Partials         0       10      +10     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/721?dropdown=coverage&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/gaze/experiment.py b/src/pymovements/gaze/experiment.py
index 6fa48db..f51da37 100644
--- a/src/pymovements/gaze/experiment.py
+++ b/src/pymovements/gaze/experiment.py
@@ -35,19 +35,19 @@ class Experiment:
 
     Parameters
     ----------
-    screen_width_px: int
-        Screen width in pixels
-    screen_height_px: int
-        Screen height in pixels
-    screen_width_cm: float
-        Screen width in centimeters
-    screen_height_cm: float
-        Screen height in centimeters
+    screen_width_px: int | None
+        Screen width in pixels. (default: None)
+    screen_height_px: int | None
+        Screen height in pixels. (default: None)
+    screen_width_cm: float | None
+        Screen width in centimeters. (default: None)
+    screen_height_cm: float | None
+        Screen height in centimeters. (default: None)
     distance_cm: float | None
         Eye-to-screen distance in centimeters. If None, a `distance_column` must be provided
         in the `DatasetDefinition` or `GazeDataFrame`, which contains the eye-to-screen
         distance for each sample in millimeters. (default: None)
-    origin: str
+    origin: str | None
         Specifies the screen location of the origin of the pixel coordinate system.
         (default: 'upper left')
     sampling_rate: float | None
@@ -94,12 +94,12 @@ class Experiment:
 
     def __init__(
             self,
-            screen_width_px: int,
-            screen_height_px: int,
-            screen_width_cm: float,
-            screen_height_cm: float,
+            screen_width_px: int | None = None,
+            screen_height_px: int | None = None,
+            screen_width_cm: float | None = None,
+            screen_height_cm: float | None = None,
             distance_cm: float | None = None,
-            origin: str = 'upper left',
+            origin: str | None = 'upper left',
             sampling_rate: float | None = None,
             eyetracker: EyeTracker | None = None,
     ):
diff --git a/src/pymovements/gaze/screen.py b/src/pymovements/gaze/screen.py
index 85c6d2a..ea44588 100644
--- a/src/pymovements/gaze/screen.py
+++ b/src/pymovements/gaze/screen.py
@@ -35,19 +35,19 @@ class Screen:
 
     Parameters
     ----------
-    width_px: int
-        Screen width in pixels
-    height_px: int
-        Screen height in pixels
-    width_cm: float
-        Screen width in centimeters
-    height_cm: float
-        Screen height in centimeters
+    width_px: int | None
+        Screen width in pixels. (default: None)
+    height_px: int | None
+        Screen height in pixels. (default: None)
+    width_cm: float | None
+        Screen width in centimeters. (default: None)
+    height_cm: float | None
+        Screen height in centimeters. (default: None)
     distance_cm: float | None
         Eye-to-screen distance in centimeters. If None, a `distance_column` must be provided
         in the `DatasetDefinition` or `GazeDataFrame`, which contains the eye-to-screen
         distance for each sample in millimeters. (default: None)
-    origin: str
+    origin: str | None
         Specifies the screen location of the origin of the pixel
         coordinate system. (default: 'upper left')
 
@@ -104,34 +104,50 @@ class Screen:
 
     def __init__(
             self,
-            width_px: int,
-            height_px: int,
-            width_cm: float,
-            height_cm: float,
+            width_px: int | None = None,
+            height_px: int | None = None,
+            width_cm: float | None = None,
+            height_cm: float | None = None,
             distance_cm: float | None = None,
-            origin: str = 'upper left',
+            origin: str | None = 'upper left',
     ):
-        checks.check_no_zeros(width_px, 'width_px')
-        checks.check_no_zeros(height_px, 'height_px')
-        checks.check_no_zeros(width_cm, 'width_cm')
-        checks.check_no_zeros(height_cm, 'height_cm')
-
-        if distance_cm is not None:
-            checks.check_no_zeros(distance_cm, 'distance_cm')
-
+        if width_px is not None:
+            checks.check_is_greater_than_zero(width_px=width_px)
         self.width_px = width_px
+
+        if height_px is not None:
+            checks.check_is_greater_than_zero(height_px=height_px)
         self.height_px = height_px
+
+        if width_cm is not None:
+            checks.check_is_greater_than_zero(width_cm=width_cm)
         self.width_cm = width_cm
+
+        if height_cm is not None:
+            checks.check_is_greater_than_zero(height_cm=height_cm)
         self.height_cm = height_cm
+
+        if distance_cm is not None:
+            checks.check_is_greater_than_zero(distance_cm=distance_cm)
         self.distance_cm = distance_cm
+
         self.origin = origin
 
     @property
     def x_max_dva(self) -> float:
         """Maximum screen x-coordinate in degrees of visual angle."""
-        self._check_distance_cm()
+        self._check_numerical_attribute('width_px')
+        assert self.width_px is not None
+
+        self._check_numerical_attribute('width_cm')
+        assert self.width_cm is not None
+
+        self._check_numerical_attribute('distance_cm')
         assert self.distance_cm is not None
 
+        checks.check_is_not_none(origin=self.origin)
+        assert self.origin is not None
+
         return float(
             transforms_numpy.pix2deg(
                 self.width_px - 1,
@@ -145,9 +161,18 @@ class Screen:
     @property
     def y_max_dva(self) -> float:
         """Maximum screen y-coordinate in degrees of visual angle."""
-        self._check_distance_cm()
+        self._check_numerical_attribute('height_px')
+        assert self.height_px is not None
+
+        self._check_numerical_attribute('height_cm')
+        assert self.height_cm is not None
+
+        self._check_numerical_attribute('distance_cm')
         assert self.distance_cm is not None
 
+        checks.check_is_not_none(origin=self.origin)
+        assert self.origin is not None
+
         return float(
             transforms_numpy.pix2deg(
                 self.height_px - 1,
@@ -161,9 +186,18 @@ class Screen:
     @property
     def x_min_dva(self) -> float:
         """Minimum screen x-coordinate in degrees of visual angle."""
-        self._check_distance_cm()
+        self._check_numerical_attribute('width_px')
+        assert self.width_px is not None
+
+        self._check_numerical_attribute('width_cm')
+        assert self.width_cm is not None
+
+        self._check_numerical_attribute('distance_cm')
         assert self.distance_cm is not None
 
+        checks.check_is_not_none(origin=self.origin)
+        assert self.origin is not None
+
         return float(
             transforms_numpy.pix2deg(
                 0,
@@ -177,9 +211,18 @@ class Screen:
     @property
     def y_min_dva(self) -> float:
         """Minimum screen y-coordinate in degrees of visual angle."""
-        self._check_distance_cm()
+        self._check_numerical_attribute('height_px')
+        assert self.height_px is not None
+
+        self._check_numerical_attribute('height_cm')
+        assert self.height_cm is not None
+
+        self._check_numerical_attribute('distance_cm')
         assert self.distance_cm is not None
 
+        checks.check_is_not_none(origin=self.origin)
+        assert self.origin is not None
+
         return float(
             transforms_numpy.pix2deg(
                 0,
@@ -236,9 +279,24 @@ class Screen:
         >>> screen.pix2deg(arr=arr)
         array([[ 3.07379946, 20.43909054]])
         """
-        self._check_distance_cm()
+        self._check_numerical_attribute('width_px')
+        assert self.width_px is not None
+
+        self._check_numerical_attribute('height_px')
+        assert self.height_px is not None
+
+        self._check_numerical_attribute('width_cm')
+        assert self.width_cm is not None
+
+        self._check_numerical_attribute('height_cm')
+        assert self.height_cm is not None
+
+        self._check_numerical_attribute('distance_cm')
         assert self.distance_cm is not None
 
+        checks.check_is_not_none(origin=self.origin)
+        assert self.origin is not None
+
         return transforms_numpy.pix2deg(
             arr=arr,
             screen_px=(self.width_px, self.height_px),
@@ -247,9 +305,9 @@ class Screen:
             origin=self.origin,
         )
 
-    def _check_distance_cm(self) -> None:
-        """Check if distance_cm is not None."""
-        if self.distance_cm is None:
-            raise ValueError(
-                'distance_cm must not be None when using this method',
-            )
+    def _check_numerical_attribute(self, key: str) -> None:
+        """Check if numerical attribute is not None and greater than zero."""
+        value = getattr(self, key, None)
+        checks.check_is_not_none(**{key: value})
+        assert isinstance(value, (int, float))
+        checks.check_is_greater_than_zero(**{key: value})
diff --git a/src/pymovements/plotting/heatmap.py b/src/pymovements/plotting/heatmap.py
index dc2ab68..d1038e3 100644
--- a/src/pymovements/plotting/heatmap.py
+++ b/src/pymovements/plotting/heatmap.py
@@ -120,6 +120,11 @@ def heatmap(
         xmin, xmax = int(x.min()), int(x.max())
         ymin, ymax = int(y.min()), int(y.max())
 
+    assert xmin is not None
+    assert xmax is not None
+    assert ymin is not None
+    assert ymax is not None
+
     # Define the grid and bin the gaze data
     x_bins = np.linspace(xmin, xmax, num=gridsize[0]).astype(int)
     y_bins = np.linspace(ymin, ymax, num=gridsize[1]).astype(int)


Test patch:
diff --git a/tests/unit/gaze/screen_test.py b/tests/unit/gaze/screen_test.py
index 4ede99c..db2311f 100644
--- a/tests/unit/gaze/screen_test.py
+++ b/tests/unit/gaze/screen_test.py
@@ -26,7 +26,7 @@ import pymovements as pm
 @pytest.mark.parametrize('property_name', ['x_max_dva', 'y_max_dva', 'x_min_dva', 'y_min_dva'])
 def test_dva_properties_with_no_distance_cm(property_name):
     screen = pm.Screen(1920, 1080, 30, 20, None, 'upper left')
-    with pytest.raises(ValueError):
+    with pytest.raises(TypeError):
         getattr(screen, property_name)
 
 
@@ -39,10 +39,67 @@ def test_dva_properties_with_distance_cm(property_name):
 
 def test_screen_pix2deg_with_no_distance_cm():
     screen = pm.Screen(1920, 1080, 30, 20, None, 'upper left')
-    with pytest.raises(ValueError):
+    with pytest.raises(TypeError):
         screen.pix2deg([[0, 0]])
 
 
 def test_screen_pix2deg_with_distance_cm():
     screen = pm.Screen(1920, 1080, 30, 20, 60, 'upper left')
     screen.pix2deg([[0, 0]])
+
+
+@pytest.mark.parametrize(
+    ('missing_attribute', 'exception', 'exception_msg'),
+    [
+        pytest.param(
+            'width_px',
+            TypeError,
+            "'width_px' must not be None",
+            id='width_px',
+        ),
+        pytest.param(
+            'height_px',
+            TypeError,
+            "'height_px' must not be None",
+            id='height_px',
+        ),
+        pytest.param(
+            'width_cm',
+            TypeError,
+            "'width_cm' must not be None",
+            id='width_cm',
+        ),
+        pytest.param(
+            'height_cm',
+            TypeError,
+            "'height_cm' must not be None",
+            id='height_cm',
+        ),
+        pytest.param(
+            'distance_cm',
+            TypeError,
+            "'distance_cm' must not be None",
+            id='distance_cm',
+        ),
+        pytest.param(
+            'origin',
+            TypeError,
+            "'origin' must not be None",
+            id='origin',
+        ),
+    ],
+)
+def test_pix2deg_without_attributes(missing_attribute, exception, exception_msg):
+    screen = pm.Screen(1920, 1080, 30, 20, 68.0, 'upper left')
+    setattr(screen, missing_attribute, None)
+
+    with pytest.raises(exception) as excinfo:
+        screen.pix2deg([[0, 0]])
+
+    msg, = excinfo.value.args
+    assert msg == exception_msg
+
+
+def test_screen_init_without_attributes():
+    screen = pm.Screen()
+    assert isinstance(screen, pm.Screen)