Repository: aeye-lab/pymovements
Pull request #1010 (2025-03-07 12:27:15)

feat: improve `Experiment` class
# :boom: (not really a) Breaking Change
Restricts eyetracker argument to keyword-only. As `sampling_rate` and `eyetracker` already was mutually exclusive, I don't see how this would break any existing workflow.

## Description

- restricts eyetracker argument to keyword-only
- implements equality operator #919 
- resolves type ambivalence of eyetracker field #891 
- adds screen argument
- necessary changes to experiment field in `DatasetDefinition` as `Experiment` now allows for empty init
- remove irrelevant experiment field from some `DatasetDefinitions`

This PR now also allows for full equality comparisons between `DatasetDefinition` instances.

Blocked by #1008


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1010?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 `89.18919%` with `4 lines` in your changes missing coverage. Please review.
> Project coverage is 42.08%. Comparing base [(`2d9da18`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/2d9da18bb292ef6b39a95130d06bae90bbd88fd8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`1734594`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/173459423b7b23ba559b236aca38e4d25f520654?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

| [Files with missing lines](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1010?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/experiment.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1010?src=pr&el=tree&filepath=src%2Fpymovements%2Fgaze%2Fexperiment.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZXhwZXJpbWVudC5weQ==) | 77.77% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |

> :exclamation:  There is a different number of reports uploaded between BASE (2d9da18) and HEAD (1734594). Click for more details.
> 
> <details><summary>HEAD has 6 uploads less than BASE</summary>
>
>| Flag | BASE (2d9da18) | HEAD (1734594) |
>|------|------|------|
>||15|9|
></details>

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


```diff
@@             Coverage Diff              @@
##              main    #1010       +/-   ##
============================================
- Coverage   100.00%   42.08%   -57.92%     
============================================
  Files           79       78        -1     
  Lines         3586     3569       -17     
  Branches       632      630        -2     
============================================
- Hits          3586     1502     -2084     
- Misses           0     2028     +2028     
- Partials         0       39       +39     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1010?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).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/src/pymovements/dataset/dataset_definition.py b/src/pymovements/dataset/dataset_definition.py
index d724a5f..05192bd 100644
--- a/src/pymovements/dataset/dataset_definition.py
+++ b/src/pymovements/dataset/dataset_definition.py
@@ -136,7 +136,8 @@ class DatasetDefinition:
 
     resources: dict[str, tuple[dict[str, str], ...]] = field(default_factory=dict)
 
-    experiment: Experiment | None = None
+    experiment: Experiment | None = field(default_factory=Experiment)
+
     extract: dict[str, bool] = field(default_factory=dict)
 
     filename_format: dict[str, str] = field(default_factory=dict)
diff --git a/src/pymovements/datasets/bsc.py b/src/pymovements/datasets/bsc.py
index 80706ea..6412f4e 100644
--- a/src/pymovements/datasets/bsc.py
+++ b/src/pymovements/datasets/bsc.py
@@ -26,7 +26,6 @@ from typing import Any
 
 from pymovements.dataset.dataset_definition import DatasetDefinition
 from pymovements.dataset.dataset_library import register_dataset
-from pymovements.gaze.experiment import Experiment
 
 
 @dataclass
@@ -64,9 +63,6 @@ class BSC(DatasetDefinition):
     extract: dict[str, bool]
         Decide whether to extract the data.
 
-    experiment: Experiment
-        The experiment definition.
-
     filename_format: dict[str, str]
         Regular expression which will be matched before trying to load the file. Namedgroups will
         appear in the `fileinfo` dataframe.
@@ -158,11 +154,6 @@ class BSC(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=None, screen_height_px=None, screen_width_cm=None,
-        screen_height_cm=None, distance_cm=None, origin=None, sampling_rate=1,
-    )
-
     filename_format: dict[str, str] = field(
         default_factory=lambda:
             {
diff --git a/src/pymovements/datasets/bsc2.py b/src/pymovements/datasets/bsc2.py
index b6f3e8a..8d06403 100644
--- a/src/pymovements/datasets/bsc2.py
+++ b/src/pymovements/datasets/bsc2.py
@@ -26,7 +26,6 @@ from typing import Any
 
 from pymovements.dataset.dataset_definition import DatasetDefinition
 from pymovements.dataset.dataset_library import register_dataset
-from pymovements.gaze.experiment import Experiment
 
 
 @dataclass
@@ -65,9 +64,6 @@ class BSCII(DatasetDefinition):
     extract: dict[str, bool]
         Decide whether to extract the data.
 
-    experiment: Experiment
-        The experiment definition.
-
     filename_format: dict[str, str]
         Regular expression which will be matched before trying to load the file. Namedgroups will
         appear in the `fileinfo` dataframe.
@@ -159,11 +155,6 @@ class BSCII(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=None, screen_height_px=None, screen_width_cm=None,
-        screen_height_cm=None, distance_cm=None, origin=None, sampling_rate=1,
-    )
-
     filename_format: dict[str, str] = field(
         default_factory=lambda:
             {
diff --git a/src/pymovements/datasets/codecomprehension.py b/src/pymovements/datasets/codecomprehension.py
index a67822b..077974d 100644
--- a/src/pymovements/datasets/codecomprehension.py
+++ b/src/pymovements/datasets/codecomprehension.py
@@ -155,14 +155,16 @@ class CodeComprehension(DatasetDefinition):
 
     extract: dict[str, bool] = field(default_factory=lambda: {'precomputed_events': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=None,
-        screen_height_px=None,
-        screen_width_cm=None,
-        screen_height_cm=None,
-        distance_cm=None,
-        origin=None,
-        sampling_rate=2000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=None,
+            screen_height_px=None,
+            screen_width_cm=None,
+            screen_height_cm=None,
+            distance_cm=None,
+            origin=None,
+            sampling_rate=2000,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/copco.py b/src/pymovements/datasets/copco.py
index 884858c..5eb262f 100644
--- a/src/pymovements/datasets/copco.py
+++ b/src/pymovements/datasets/copco.py
@@ -170,14 +170,16 @@ class CopCo(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1920,
-        screen_height_px=1080,
-        screen_width_cm=59.,
-        screen_height_cm=33.5,
-        distance_cm=85,
-        origin='center',
-        sampling_rate=1000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1920,
+            screen_height_px=1080,
+            screen_width_cm=59.,
+            screen_height_cm=33.5,
+            distance_cm=85,
+            origin='center',
+            sampling_rate=1000,
+        ),
     )
 
     extract: dict[str, bool] = field(
diff --git a/src/pymovements/datasets/daemons.py b/src/pymovements/datasets/daemons.py
index 8348b4f..17c8e0b 100644
--- a/src/pymovements/datasets/daemons.py
+++ b/src/pymovements/datasets/daemons.py
@@ -26,7 +26,6 @@ from typing import Any
 
 from pymovements.dataset.dataset_definition import DatasetDefinition
 from pymovements.dataset.dataset_library import register_dataset
-from pymovements.gaze.experiment import Experiment
 
 
 @dataclass
@@ -69,9 +68,6 @@ class DAEMONS(DatasetDefinition):
     extract: dict[str, bool]
         Decide whether to extract the data.
 
-    experiment: Experiment
-        The experiment definition.
-
     filename_format: dict[str, str]
         Regular expression which will be matched before trying to load the file. Namedgroups will
         appear in the `fileinfo` dataframe.
@@ -153,11 +149,6 @@ class DAEMONS(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=None, screen_height_px=None, screen_width_cm=None,
-        screen_height_cm=None, distance_cm=None, origin=None, sampling_rate=1,
-    )
-
     filename_format: dict[str, str] = field(
         default_factory=lambda:
             {
diff --git a/src/pymovements/datasets/didec.py b/src/pymovements/datasets/didec.py
index a532368..04ece4f 100644
--- a/src/pymovements/datasets/didec.py
+++ b/src/pymovements/datasets/didec.py
@@ -150,14 +150,16 @@ class DIDEC(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1680,
-        screen_height_px=1050,
-        screen_width_cm=47.4,
-        screen_height_cm=29.7,
-        distance_cm=70,
-        origin='upper left',
-        sampling_rate=1000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1680,
+            screen_height_px=1050,
+            screen_width_cm=47.4,
+            screen_height_cm=29.7,
+            distance_cm=70,
+            origin='upper left',
+            sampling_rate=1000,
+        ),
     )
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
diff --git a/src/pymovements/datasets/emtec.py b/src/pymovements/datasets/emtec.py
index fd026cd..ec155e5 100644
--- a/src/pymovements/datasets/emtec.py
+++ b/src/pymovements/datasets/emtec.py
@@ -165,14 +165,16 @@ class EMTeC(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1280,
-        screen_height_px=1024,
-        screen_width_cm=38.2,
-        screen_height_cm=30.2,
-        distance_cm=60,
-        origin='center',
-        sampling_rate=2000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1280,
+            screen_height_px=1024,
+            screen_width_cm=38.2,
+            screen_height_cm=30.2,
+            distance_cm=60,
+            origin='center',
+            sampling_rate=2000,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/fakenews.py b/src/pymovements/datasets/fakenews.py
index 243da81..814d531 100644
--- a/src/pymovements/datasets/fakenews.py
+++ b/src/pymovements/datasets/fakenews.py
@@ -118,14 +118,16 @@ class FakeNewsPerception(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1920,
-        screen_height_px=1080,
-        screen_width_cm=52.7,
-        screen_height_cm=29.6,
-        distance_cm=None,
-        origin=None,
-        sampling_rate=600,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1920,
+            screen_height_px=1080,
+            screen_width_cm=52.7,
+            screen_height_cm=29.6,
+            distance_cm=None,
+            origin=None,
+            sampling_rate=600,
+        ),
     )
 
     extract: dict[str, bool] = field(default_factory=lambda: {'precomputed_events': True})
diff --git a/src/pymovements/datasets/gaze_graph.py b/src/pymovements/datasets/gaze_graph.py
index 98038d4..132d5a0 100644
--- a/src/pymovements/datasets/gaze_graph.py
+++ b/src/pymovements/datasets/gaze_graph.py
@@ -158,14 +158,16 @@ class GazeGraph(DatasetDefinition):
     )
 
     # no information about the resolution and screen size given. only 34-inch monitor
-    experiment: Experiment = Experiment(
-        screen_width_px=3440,
-        screen_height_px=1440,
-        screen_width_cm=79.375,
-        screen_height_cm=34.0106,
-        distance_cm=50,
-        origin='center',
-        sampling_rate=30,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=3440,
+            screen_height_px=1440,
+            screen_width_cm=79.375,
+            screen_height_cm=34.0106,
+            distance_cm=50,
+            origin='center',
+            sampling_rate=30,
+        ),
     )
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
diff --git a/src/pymovements/datasets/gaze_on_faces.py b/src/pymovements/datasets/gaze_on_faces.py
index 66c97af..87e96b3 100644
--- a/src/pymovements/datasets/gaze_on_faces.py
+++ b/src/pymovements/datasets/gaze_on_faces.py
@@ -159,14 +159,16 @@ class GazeOnFaces(DatasetDefinition):
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1280,
-        screen_height_px=1024,
-        screen_width_cm=38,
-        screen_height_cm=30,
-        distance_cm=57,
-        origin='center',
-        sampling_rate=60,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1280,
+            screen_height_px=1024,
+            screen_width_cm=38,
+            screen_height_cm=30,
+            distance_cm=57,
+            origin='center',
+            sampling_rate=60,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/gazebase.py b/src/pymovements/datasets/gazebase.py
index 51f7383..3629a42 100644
--- a/src/pymovements/datasets/gazebase.py
+++ b/src/pymovements/datasets/gazebase.py
@@ -162,14 +162,16 @@ class GazeBase(DatasetDefinition):
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1680,
-        screen_height_px=1050,
-        screen_width_cm=47.4,
-        screen_height_cm=29.7,
-        distance_cm=55,
-        origin='upper left',
-        sampling_rate=1000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1680,
+            screen_height_px=1050,
+            screen_width_cm=47.4,
+            screen_height_cm=29.7,
+            distance_cm=55,
+            origin='upper left',
+            sampling_rate=1000,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/gazebasevr.py b/src/pymovements/datasets/gazebasevr.py
index 102cae9..9304f1f 100644
--- a/src/pymovements/datasets/gazebasevr.py
+++ b/src/pymovements/datasets/gazebasevr.py
@@ -164,14 +164,16 @@ class GazeBaseVR(DatasetDefinition):
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1680,
-        screen_height_px=1050,
-        screen_width_cm=47.4,
-        screen_height_cm=29.7,
-        distance_cm=55,
-        origin='center',
-        sampling_rate=250,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1680,
+            screen_height_px=1050,
+            screen_width_cm=47.4,
+            screen_height_cm=29.7,
+            distance_cm=55,
+            origin='center',
+            sampling_rate=250,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/hbn.py b/src/pymovements/datasets/hbn.py
index 674eb8a..62364c6 100644
--- a/src/pymovements/datasets/hbn.py
+++ b/src/pymovements/datasets/hbn.py
@@ -157,14 +157,16 @@ class HBN(DatasetDefinition):
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=800,
-        screen_height_px=600,
-        screen_width_cm=33.8,
-        screen_height_cm=27.0,
-        distance_cm=63.5,
-        origin='center',
-        sampling_rate=120,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=800,
+            screen_height_px=600,
+            screen_width_cm=33.8,
+            screen_height_cm=27.0,
+            distance_cm=63.5,
+            origin='center',
+            sampling_rate=120,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/interead.py b/src/pymovements/datasets/interead.py
index b9f0f68..0741ec4 100644
--- a/src/pymovements/datasets/interead.py
+++ b/src/pymovements/datasets/interead.py
@@ -169,14 +169,16 @@ class InteRead(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1920,
-        screen_height_px=1080,
-        screen_width_cm=52.8,
-        screen_height_cm=29.7,
-        distance_cm=57,
-        origin='center',
-        sampling_rate=1200,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1920,
+            screen_height_px=1080,
+            screen_width_cm=52.8,
+            screen_height_cm=29.7,
+            distance_cm=57,
+            origin='center',
+            sampling_rate=1200,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/judo1000.py b/src/pymovements/datasets/judo1000.py
index c8e366a..97e6e79 100644
--- a/src/pymovements/datasets/judo1000.py
+++ b/src/pymovements/datasets/judo1000.py
@@ -156,14 +156,16 @@ class JuDo1000(DatasetDefinition):
     )
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1280,
-        screen_height_px=1024,
-        screen_width_cm=38,
-        screen_height_cm=30.2,
-        distance_cm=68,
-        origin='upper left',
-        sampling_rate=1000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1280,
+            screen_height_px=1024,
+            screen_width_cm=38,
+            screen_height_cm=30.2,
+            distance_cm=68,
+            origin='upper left',
+            sampling_rate=1000,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/potec.py b/src/pymovements/datasets/potec.py
index ab83990..417ecb3 100644
--- a/src/pymovements/datasets/potec.py
+++ b/src/pymovements/datasets/potec.py
@@ -162,14 +162,16 @@ class PoTeC(DatasetDefinition):
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1680,
-        screen_height_px=1050,
-        screen_width_cm=47.5,
-        screen_height_cm=30,
-        distance_cm=65,
-        origin='upper left',
-        sampling_rate=1000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1680,
+            screen_height_px=1050,
+            screen_width_cm=47.5,
+            screen_height_cm=30,
+            distance_cm=65,
+            origin='upper left',
+            sampling_rate=1000,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/provo.py b/src/pymovements/datasets/provo.py
index f318fec..39886db 100644
--- a/src/pymovements/datasets/provo.py
+++ b/src/pymovements/datasets/provo.py
@@ -28,7 +28,6 @@ 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
 
 
 @dataclass
@@ -67,9 +66,6 @@ class Provo(DatasetDefinition):
     extract: dict[str, bool]
         Decide whether to extract the data.
 
-    experiment: Experiment
-        The experiment definition.
-
     filename_format: dict[str, str]
         Regular expression which will be matched before trying to load the file. Namedgroups will
         appear in the `fileinfo` dataframe.
@@ -161,11 +157,6 @@ class Provo(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=None, screen_height_px=None, screen_width_cm=None,
-        screen_height_cm=None, distance_cm=None, origin=None, sampling_rate=1,
-    )
-
     filename_format: dict[str, str] = field(
         default_factory=lambda:
             {
diff --git a/src/pymovements/datasets/sb_sat.py b/src/pymovements/datasets/sb_sat.py
index e1fedea..19c7ace 100644
--- a/src/pymovements/datasets/sb_sat.py
+++ b/src/pymovements/datasets/sb_sat.py
@@ -170,14 +170,16 @@ class SBSAT(DatasetDefinition):
         },
     )
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1024,
-        screen_height_px=768,
-        screen_width_cm=44.5,
-        screen_height_cm=42.4,
-        distance_cm=70,
-        origin='center',
-        sampling_rate=1000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1024,
+            screen_height_px=768,
+            screen_width_cm=44.5,
+            screen_height_cm=42.4,
+            distance_cm=70,
+            origin='center',
+            sampling_rate=1000,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/toy_dataset.py b/src/pymovements/datasets/toy_dataset.py
index f7a6345..954df4d 100644
--- a/src/pymovements/datasets/toy_dataset.py
+++ b/src/pymovements/datasets/toy_dataset.py
@@ -152,14 +152,16 @@ class ToyDataset(DatasetDefinition):
     )
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1280,
-        screen_height_px=1024,
-        screen_width_cm=38,
-        screen_height_cm=30.2,
-        distance_cm=68,
-        origin='upper left',
-        sampling_rate=1000,
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1280,
+            screen_height_px=1024,
+            screen_width_cm=38,
+            screen_height_cm=30.2,
+            distance_cm=68,
+            origin='upper left',
+            sampling_rate=1000,
+        ),
     )
 
     filename_format: dict[str, str] = field(
diff --git a/src/pymovements/datasets/toy_dataset_eyelink.py b/src/pymovements/datasets/toy_dataset_eyelink.py
index 8e7ff91..f932e85 100644
--- a/src/pymovements/datasets/toy_dataset_eyelink.py
+++ b/src/pymovements/datasets/toy_dataset_eyelink.py
@@ -157,19 +157,21 @@ class ToyDatasetEyeLink(DatasetDefinition):
 
     extract: dict[str, bool] = field(default_factory=lambda: {'gaze': True})
 
-    experiment: Experiment = Experiment(
-        screen_width_px=1280,
-        screen_height_px=1024,
-        screen_width_cm=38,
-        screen_height_cm=30.2,
-        distance_cm=68,
-        origin='upper left',
-        eyetracker=EyeTracker(
-            sampling_rate=1000.0,
-            left=True,
-            right=False,
-            model='EyeLink Portable Duo',
-            vendor='EyeLink',
+    experiment: Experiment = field(
+        default_factory=lambda: Experiment(
+            screen_width_px=1280,
+            screen_height_px=1024,
+            screen_width_cm=38,
+            screen_height_cm=30.2,
+            distance_cm=68,
+            origin='upper left',
+            eyetracker=EyeTracker(
+                sampling_rate=1000.0,
+                left=True,
+                right=False,
+                model='EyeLink Portable Duo',
+                vendor='EyeLink',
+            ),
         ),
     )
 
diff --git a/src/pymovements/gaze/experiment.py b/src/pymovements/gaze/experiment.py
index 5f7162b..023b977 100644
--- a/src/pymovements/gaze/experiment.py
+++ b/src/pymovements/gaze/experiment.py
@@ -20,8 +20,6 @@
 """Provides the Experiment class."""
 from __future__ import annotations
 
-from typing import Any
-
 import numpy as np
 
 from pymovements.gaze import transforms_numpy
@@ -49,11 +47,14 @@ class Experiment:
         distance for each sample in millimeters. (default: None)
     origin: str | None
         Specifies the screen location of the origin of the pixel coordinate system.
-        (default: 'upper left')
+        (default: None)
     sampling_rate: float | None
         Sampling rate in Hz. (default: None)
+    screen : Screen | None
+        Scree object for experiment. Mutually exclusive with explicit screen arguments.
+        (default: None)
     eyetracker : EyeTracker | None
-        EyeTracker object for experiment. (default: None)
+        EyeTracker object for experiment. Mutually exclusive with sampling_rate. (default: None)
 
     Examples
     --------
@@ -67,8 +68,9 @@ class Experiment:
     ...     sampling_rate=1000.0,
     ... )
     >>> print(experiment)
-    Experiment(sampling_rate=1000.00, screen=Screen(width_px=1280, height_px=1024, width_cm=38.0,
-    height_cm=30.0, distance_cm=68.0, origin='upper left'), eyetracker=None)
+    Experiment(screen=Screen(width_px=1280, height_px=1024, width_cm=38.0, height_cm=30.0,
+     distance_cm=68.0, origin='upper left'), eyetracker=EyeTracker(sampling_rate=1000.0, left=None,
+      right=None, model=None, version=None, vendor=None, mount=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
@@ -93,42 +95,49 @@ class Experiment:
             distance_cm: float | None = None,
             origin: str | None = 'upper left',
             sampling_rate: float | None = None,
+            *,
+            screen: Screen | None = None,
             eyetracker: EyeTracker | None = None,
     ):
-        self.screen = Screen(
-            width_px=screen_width_px,
-            height_px=screen_height_px,
-            width_cm=screen_width_cm,
-            height_cm=screen_height_cm,
-            distance_cm=distance_cm,
-            origin=origin,
-        )
-
+        checks.check_is_mutual_exclusive(screen_width_px=screen_width_px, screen=screen)
+        checks.check_is_mutual_exclusive(screen_height_px=screen_height_px, screen=screen)
+        checks.check_is_mutual_exclusive(screen_width_cm=screen_width_cm, screen=screen)
+        checks.check_is_mutual_exclusive(screen_height_cm=screen_height_cm, screen=screen)
+        checks.check_is_mutual_exclusive(distance_cm=distance_cm, screen=screen)
         checks.check_is_mutual_exclusive(sampling_rate=sampling_rate, eyetracker=eyetracker)
 
+        # the origin default value needs special care to not mess with a passed screen.
+        if origin == 'upper left' and screen is not None:
+            origin = None  # set origin to None to pass mutual exclusivity check
+        checks.check_is_mutual_exclusive(origin=origin, screen=screen)
+
+        if screen is None:
+            screen = Screen(
+                width_px=screen_width_px,
+                height_px=screen_height_px,
+                width_cm=screen_width_cm,
+                height_cm=screen_height_cm,
+                distance_cm=distance_cm,
+                origin=origin,
+            )
+        self.screen = screen
+
+        if eyetracker is None:
+            eyetracker = EyeTracker(sampling_rate=sampling_rate)
         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)
+        if 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
-
-        assert self.eyetracker is not None
-
         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
+        self.eyetracker.sampling_rate = sampling_rate
 
     def pos2vel(
             self,
@@ -189,17 +198,12 @@ class Experiment:
             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)}'
+    def __eq__(self: Experiment, other: Experiment) -> bool:
+        """Compare equality to other Experiment."""
+        print(self.screen, other.screen, self.screen == other.screen)
+        print(self.eyetracker, other.eyetracker, self.eyetracker == other.eyetracker)
+        return self.screen == other.screen and self.eyetracker == other.eyetracker
 
-        return f'{type(self).__name__}(sampling_rate={shorten(self.sampling_rate)}{attributes})'
+    def __str__(self: Experiment) -> str:
+        """Return Experiment string."""
+        return f'{type(self).__name__}(screen={self.screen}, eyetracker={self.eyetracker})'
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index 4e97e4c..686ddd8 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -165,8 +165,9 @@ class GazeDataFrame:
     >>> experiment = Experiment(1024, 768, 38, 30, 60, 'center', sampling_rate=100)
     >>> gaze = GazeDataFrame(data=df_no_time, experiment=experiment, pixel_columns=['x', 'y'])
     >>> gaze
-    Experiment(sampling_rate=100, screen=Screen(width_px=1024, height_px=768, width_cm=38,
-    height_cm=30, distance_cm=60, origin='center'), eyetracker=None)
+    Experiment(screen=Screen(width_px=1024, height_px=768, width_cm=38, height_cm=30,
+     distance_cm=60, origin='center'), eyetracker=EyeTracker(sampling_rate=100, left=None,
+      right=None, model=None, version=None, vendor=None, mount=None))
     shape: (3, 2)
     ┌──────┬────────────┐
     │ time ┆ pixel      │
diff --git a/src/pymovements/gaze/io.py b/src/pymovements/gaze/io.py
index c449752..5fef8e3 100644
--- a/src/pymovements/gaze/io.py
+++ b/src/pymovements/gaze/io.py
@@ -27,7 +27,6 @@ import polars as pl
 
 from pymovements.gaze._utils.parsing import parse_eyelink
 from pymovements.gaze.experiment import Experiment
-from pymovements.gaze.eyetracker import EyeTracker
 from pymovements.gaze.gaze_dataframe import GazeDataFrame  # pylint: disable=cyclic-import
 
 
@@ -376,8 +375,6 @@ def from_asc(
 
     if experiment is None:
         experiment = Experiment(sampling_rate=metadata['sampling_rate'])
-    if experiment.eyetracker is None:
-        experiment.eyetracker = EyeTracker()
 
     # Compare metadata from experiment definition with metadata from ASC file.
     # Fill in missing metadata in experiment definition and raise an error if there are conflicts
@@ -391,9 +388,7 @@ def from_asc(
         issues.append(f"Screen resolution: {experiment_resolution} vs. {metadata['resolution']}")
 
     # Sampling rate
-    if experiment.eyetracker.sampling_rate is None:
-        experiment.eyetracker.sampling_rate = metadata['sampling_rate']
-    elif experiment.eyetracker.sampling_rate != metadata['sampling_rate']:
+    if experiment.eyetracker.sampling_rate != metadata['sampling_rate']:
         issues.append(
             f"Sampling rate: {experiment.eyetracker.sampling_rate} vs. {metadata['sampling_rate']}",
         )


Test patch:
diff --git a/tests/unit/dataset/dataset_files_test.py b/tests/unit/dataset/dataset_files_test.py
index 44443fc..f5684dc 100644
--- a/tests/unit/dataset/dataset_files_test.py
+++ b/tests/unit/dataset/dataset_files_test.py
@@ -206,7 +206,7 @@ def test_load_eyelink_file(tmp_path, read_kwargs):
         filepath,
         fileinfo_row={},
         definition=DatasetDefinition(
-            experiment=pm.Experiment(1280, 1024, 38, 30, None, 'center', 100),
+            experiment=pm.Experiment(1280, 1024, 38, 30, None, 'center', 1000),
             filename_format_schema_overrides={'gaze': {}, 'precomputed_events': {}},
         ),
         custom_read_kwargs=read_kwargs,
diff --git a/tests/unit/gaze/experiment_test.py b/tests/unit/gaze/experiment_test.py
index 56acb2b..c83f9aa 100644
--- a/tests/unit/gaze/experiment_test.py
+++ b/tests/unit/gaze/experiment_test.py
@@ -20,8 +20,9 @@
 """Test for Experiment class."""
 import pytest
 
-from pymovements.gaze.experiment import Experiment
-from pymovements.gaze.eyetracker import EyeTracker
+from pymovements import Experiment
+from pymovements import EyeTracker
+from pymovements import Screen
 
 
 def test_sampling_rate_setter():
@@ -32,7 +33,48 @@ def test_sampling_rate_setter():
     assert experiment.sampling_rate == 100.0
 
 
-def test_sampling_rate_invalid():
-    with pytest.raises(TypeError):
-        eyetracker = EyeTracker()
-        Experiment(1280, 1024, 38, 30, eyetracker=eyetracker)
+@pytest.mark.parametrize(
+    'experiment_init_kwargs',
+    [
+        pytest.param(
+            {},
+            id='empty',
+        ),
+        pytest.param(
+            {'sampling_rate': 1000},
+            id='only_sampling_rate',
+        ),
+        pytest.param(
+            {'screen': Screen(1024, 768)},
+            id='only_screen',
+        ),
+        pytest.param(
+            {'screen': Screen(1024, 768), 'eyetracker': EyeTracker(sampling_rate=1000)},
+            id='screen_and_eyetracker',
+        ),
+
+    ],
+)
+def test_sampling_rate_trivial_equality(experiment_init_kwargs):
+    experiment1 = Experiment(**experiment_init_kwargs)
+    experiment2 = Experiment(**experiment_init_kwargs)
+    assert experiment1 == experiment2
+
+
+@pytest.mark.parametrize(
+    ('experiment1', 'experiment2'),
+    [
+        pytest.param(
+            Experiment(sampling_rate=1000),
+            Experiment(eyetracker=EyeTracker(sampling_rate=1000)),
+            id='explicit_sampling_rate_and_eyetracker',
+        ),
+        pytest.param(
+            Experiment(1024, 768),
+            Experiment(screen=Screen(1024, 768)),
+            id='explicit_screen_size_and_screen',
+        ),
+    ],
+)
+def test_sampling_rate_equality(experiment1, experiment2):
+    assert experiment1 == experiment2
diff --git a/tests/unit/gaze/gaze_dataframe_test.py b/tests/unit/gaze/gaze_dataframe_test.py
index 0ebde76..33eb701 100644
--- a/tests/unit/gaze/gaze_dataframe_test.py
+++ b/tests/unit/gaze/gaze_dataframe_test.py
@@ -181,13 +181,7 @@ def test_gaze_dataframe_copy_with_experiment():
 
     # We want to have separate experiment instances but the same values.
     assert gaze.experiment is not gaze_copy.experiment
-    assert gaze.experiment.screen.width_px == gaze_copy.experiment.screen.width_px
-    assert gaze.experiment.screen.height_px == gaze_copy.experiment.screen.height_px
-    assert gaze.experiment.screen.width_cm == gaze_copy.experiment.screen.width_cm
-    assert gaze.experiment.screen.height_cm == gaze_copy.experiment.screen.height_cm
-    assert gaze.experiment.screen.distance_cm == gaze_copy.experiment.screen.distance_cm
-    assert gaze.experiment.screen.origin == gaze_copy.experiment.screen.origin
-    assert gaze.experiment.sampling_rate == gaze_copy.experiment.sampling_rate
+    assert gaze.experiment == gaze_copy.experiment
 
 
 def test_gaze_dataframe_copy_no_experiment():