Repository: aeye-lab/pymovements (python)

Issue:
implement `Experiment.from_dict()`
## Description of the problem

It would be nice if the screen and eyetracker arguments in the experiment init could be overloaded to also support dictionaries.

Let's take this dict as an example:

```python
dict_definition = {
    'screen': {
        'height_cm': 30.2,
        'height_px': 1024,
        'width_cm': 38.2,
        'width_px': 1280,
        'distance_cm': 60,
        'origin': 'center',
    },
    'eyetracker': {
        'sampling_rate': 2000,
        'model': 'EyeLink II',
        'vendor': EyeLink',
    },
}
```

Trying to initialize a `DatasetDefinition` with `DatasetDefinition(**dict_definition)` wont' work as `screen` and `eyetracker` are just dicts and not the initialized class objects.

#914 implemented a workaround in its `DatsetDefinition.from_yaml()` method but it would be more elegant to solve this in the `Experiment.__init__()` itself.

## Description of a solution

The `screen` and `eyetracker` arguments in `Experiment.__init__()` should be overloaded to also support dictionaries.
If a dictionary is passed, a `Screen` or `EyeTracker` object should be initialized on the fly.

**EDIT**:

Instead of overloading `Experiment.__init__()`, please implement the method `Experiment.from_dict()`.


Pull request description:
feat: implement `Experiment.from_dict()`
## Description
This change implements the Experiment.from_dict() method to allow initialization of the Experiment object to support dictionaries for the screen and eyetracker arguments. This feature resolves issue #991 and improves code flexibility by making it easier to initialize the object with dictionary data. It is now possible to pass a dictionary for these arguments without having to manually create Screen or EyeTracker objects.

## Implemented changes
- [ I implemented "Experiment.from_dict()" ] Change 1
- [ I added the necessary tests to this code in "experiment_test.py" ] Change 2

## How Has This Been Tested?
- [ I did the command "pytest src/pymovements/gaze/experiment.py" to see if the code had no errors ] Test A
- [ I ran the command "pytest tests/unit/gaze/experiment_test.py" to see if the tests were operational ] Test B

## Type of change
- New functionality

## Context
Resolves #991 

#### related issues:
- #914

#### required by:
- None

#### requires:
- None

#### conflicts with:
- None

- ## 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
- [ ] 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
- [ ] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


Patch:
diff --git a/src/pymovements/gaze/experiment.py b/src/pymovements/gaze/experiment.py
index 1bb24f0..af85023 100644
--- a/src/pymovements/gaze/experiment.py
+++ b/src/pymovements/gaze/experiment.py
@@ -20,6 +20,7 @@
 """Provides the Experiment class."""
 from __future__ import annotations
 
+from copy import deepcopy
 from dataclasses import asdict
 from typing import Any
 
@@ -132,6 +133,80 @@ class Experiment:
         if self.sampling_rate is not None:
             _checks.check_is_greater_than_zero(sampling_rate=self.sampling_rate)
 
+    @staticmethod
+    def from_dict(dictionary: dict[str, Any]) -> Experiment:
+        """Create an Experiment instance from a dictionary.
+
+        Parameters
+        ----------
+        dictionary : dict[str, Any]
+            A dictionary containing Experiment parameters.
+
+        Notes
+        -----
+        The dictionary may contain nested dictionaries for 'screen' and 'eyetracker'.
+        These will be automatically converted into Screen and EyeTracker instances.
+
+        Examples
+        --------
+        Passing a flat dictionary:
+
+        >>> experiment = Experiment.from_dict({
+        ...     "screen_width_px": 1280,
+        ...     "screen_height_px": 1024,
+        ...     "screen_width_cm": 38.0,
+        ...     "screen_height_cm": 30.0,
+        ...     "distance_cm": 68.0,
+        ...     "sampling_rate": 1000.0,
+        ... })
+        >>> print(experiment)
+        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))
+
+        The same result using nested dictionaries for `screen` and `eyetracker`:
+
+        >>> experiment = Experiment.from_dict({
+        ...     "screen": {
+        ...         "width_px": 1280,
+        ...         "height_px": 1024,
+        ...         "width_cm": 38.0,
+        ...         "height_cm": 30.0,
+        ...         "distance_cm": 68.0,
+        ...         "origin": "upper left"
+        ...     },
+        ...     "eyetracker": {
+        ...         "sampling_rate": 1000.0
+        ...     }
+        ... })
+        >>> print(experiment)
+        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))
+
+        Returns
+        -------
+        Experiment
+            An initialized Experiment instance.
+        """
+        dictionary = deepcopy(dictionary)
+        screen = None
+        eyetracker = None
+
+        if 'screen' in dictionary:
+            screen = Screen(**dictionary.pop('screen'))
+
+        if 'eyetracker' in dictionary:
+            eyetracker = EyeTracker(**dictionary.pop('eyetracker'))
+
+        return Experiment(
+            **dictionary,
+            screen=screen,
+            eyetracker=eyetracker,
+        )
+
     @property
     def sampling_rate(self) -> float | None:
         """Get sampling rate of experiment."""


Test patch:
diff --git a/tests/unit/gaze/experiment_test.py b/tests/unit/gaze/experiment_test.py
index c83f9aa..9c2fd50 100644
--- a/tests/unit/gaze/experiment_test.py
+++ b/tests/unit/gaze/experiment_test.py
@@ -78,3 +78,36 @@ def test_sampling_rate_trivial_equality(experiment_init_kwargs):
 )
 def test_sampling_rate_equality(experiment1, experiment2):
     assert experiment1 == experiment2
+
+
+@pytest.mark.parametrize(
+    ('dictionary', 'expected_experiment'),
+    [
+        pytest.param(
+            {'sampling_rate': 1000},
+            Experiment(eyetracker=EyeTracker(sampling_rate=1000)),
+            id='sampling_rate',
+        ),
+
+        pytest.param(
+            {'eyetracker': {'sampling_rate': 1000}},
+            Experiment(eyetracker=EyeTracker(sampling_rate=1000)),
+            id='eyetracker_sampling_rate',
+        ),
+
+        pytest.param(
+            {'screen_width_px': 1024, 'screen_height_px': 768},
+            Experiment(screen=Screen(1024, 768)),
+            id='screen_width_px_and_screen_height_px',
+        ),
+
+        pytest.param(
+            {'screen': {'width_px': 1024, 'height_px': 768}},
+            Experiment(screen=Screen(1024, 768)),
+            id='screen_width_px_and_height_px',
+        ),
+    ],
+)
+def test_experiment_from_dict(dictionary, expected_experiment):
+    experiment = Experiment.from_dict(dictionary)
+    assert experiment == expected_experiment