Repository: aeye-lab/pymovements
Pull request #472 (2023-08-10 18:23:43)

chore!: Use transform() in pix2deg(), pos2vel() and pos2acc()

dkrako: There are 3 test cases which I just cannot get to pass.
The failing functions are `pos2vel()`, `pos2acc()` and `savitzky_golay()` from `pymovements.gaze.transforms` with all three having an empty series as input and expecting and empty series as output. 

The reason why they fail is coming from the following change in this PR:

OLD:
https://github.com/aeye-lab/pymovements/blob/dd122fd9d07bfebb8972abdfd3f6e6f583574cf1/src/pymovements/gaze/transforms.py#L537-L548

NEW:
https://github.com/aeye-lab/pymovements/blob/1b9d5b58eff0c9c61c9e21a3a9ca98d07d24bf6f/src/pymovements/gaze/transforms.py#L537-L542

This was changed, because refactoring the `GazeDataFrame.pos2vel()` method to use `GazeDataFrame.transform()` resulted in changes that raised a `polars.ComutationError`.

This is how `GazeDataFrame.pos2vel()` was implemented before:
https://github.com/aeye-lab/pymovements/blob/dd122fd9d07bfebb8972abdfd3f6e6f583574cf1/src/pymovements/gaze/gaze_dataframe.py#L314-L319

And this is how it is implemented now in `GazeDataFrame.transform()`:
https://github.com/aeye-lab/pymovements/blob/1b9d5b58eff0c9c61c9e21a3a9ca98d07d24bf6f/src/pymovements/gaze/gaze_dataframe.py#L275

We cannot just instantiate a `polars.Series` anymore, but need to return a `polars.Expr`.
This is the error that was raised on calling `GazeDataFrame.pos2vel()` with an empty dataframe before the changes in `savitzky_golay()`:

```
exceptions.ComputeError: The name: 'velocity' passed to `LazyFrame.with_columns` is duplicate
```
I just can't wrap my head around the issue... :exploding_head: 

As the test cases aren't very practical for most users, I would prefer to merge despite the problem and create an issue out of this. This post here should be a good starting point on how to fix the problem.

I have commented out the test cases here:

https://github.com/aeye-lab/pymovements/blob/1b9d5b58eff0c9c61c9e21a3a9ca98d07d24bf6f/tests/gaze/transforms/savitzky_golay_test.py#L167-L175

https://github.com/aeye-lab/pymovements/blob/1b9d5b58eff0c9c61c9e21a3a9ca98d07d24bf6f/tests/gaze/transforms/pos2acc_test.py#L117-L122

https://github.com/aeye-lab/pymovements/blob/1b9d5b58eff0c9c61c9e21a3a9ca98d07d24bf6f/tests/gaze/transforms/pos2vel_test.py#L160-L168
dkrako: I need to update the tutorials a bit, some are failing and some texts are not up to date anymore.

But apart from this, the PR is ready for review.

Patch:
diff --git a/docs/source/tutorials/heatmap.ipynb b/docs/source/tutorials/heatmap.ipynb
index feb3840..f5aa419 100644
--- a/docs/source/tutorials/heatmap.ipynb
+++ b/docs/source/tutorials/heatmap.ipynb
@@ -72,7 +72,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Now our available columns for each GazeDataFrame are the following. We will use the `x_right_pos` and `y_right_pos` columns for plotting. However, it is also possible to use the pixel data columns `x_right_pix` and `y_right_pix`."
+    "We now have a `position` column available: "
    ]
   },
   {
diff --git a/docs/source/tutorials/local-dataset.ipynb b/docs/source/tutorials/local-dataset.ipynb
index 6e2cfe1..bc708d6 100644
--- a/docs/source/tutorials/local-dataset.ipynb
+++ b/docs/source/tutorials/local-dataset.ipynb
@@ -360,7 +360,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "dataset.pos2vel(method='savitzky_golay', window_length=7, polyorder=2)\n",
+    "dataset.pos2vel(method='savitzky_golay', degree=2, window_length=7)\n",
     "\n",
     "dataset.gaze[0].frame"
    ]
diff --git a/docs/source/tutorials/preprocessing-raw-data.ipynb b/docs/source/tutorials/preprocessing-raw-data.ipynb
index ad84745..8807e57 100644
--- a/docs/source/tutorials/preprocessing-raw-data.ipynb
+++ b/docs/source/tutorials/preprocessing-raw-data.ipynb
@@ -106,8 +106,7 @@
    "id": "4bce5e1a",
    "metadata": {},
    "source": [
-    "Apart from some additional labels we see the following columns:\n",
-    "`time`, `x_right_pix` and `y_right_pix`."
+    "Apart from some trial identifier columns we see the columns `time` and `pixel`."
    ]
   },
   {
@@ -144,7 +143,7 @@
    "id": "e416a71b",
    "metadata": {},
    "source": [
-    "The processed result has been added as new columns to our gaze dataframe: `x_right_pos`, `y_right_pos`."
+    "The processed result has been added as a new column named `position` to our gaze dataframe."
    ]
   },
   {
@@ -158,9 +157,9 @@
     "* `preceding`: this will just take the single preceding sample in account for velocity calculation. Most noisy variant.\n",
     "* `neighbors`: this will take the neighboring samples in account for velocity calculation. A bit less noisy.\n",
     "* `smooth`: this will increase the neighboring samples to two on each side. You can get a smooth conversion this way.\n",
-    "* `savitzky_golay`: this is using the *Savitzky-Golay* differentiation filter for conversion. You can specify additional parameters like `window_length` and `polyorder`. Depending on your parameters this will lead to the best results.\n",
+    "* `savitzky_golay`: this is using the *Savitzky-Golay* differentiation filter for conversion. You can specify additional parameters like `window_length` and `degree`. Depending on your parameters this will lead to the best results.\n",
     "\n",
-    "Let's use the `smooth` method first:"
+    "Let's use the `fivepoint` method first:"
    ]
   },
   {
@@ -170,7 +169,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "dataset.pos2vel(method='smooth')\n",
+    "dataset.pos2vel(method='fivepoint')\n",
     "\n",
     "dataset.gaze[0].frame"
    ]
@@ -180,7 +179,7 @@
    "id": "98d43a6e",
    "metadata": {},
    "source": [
-    "This added the following new velocity columns to our gaze dataframe: `x_right_vel`, `y_right_vel`"
+    "The processed result has been added as a new column named `velocity` to our gaze dataframe."
    ]
   },
   {
@@ -198,7 +197,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "dataset.pos2vel(method='savitzky_golay', window_length=7, polyorder=2)\n",
+    "dataset.pos2vel(method='savitzky_golay', degree=2, window_length=7)\n",
     "\n",
     "dataset.gaze[0].frame"
    ]
diff --git a/docs/source/tutorials/pymovements-in-10-minutes.ipynb b/docs/source/tutorials/pymovements-in-10-minutes.ipynb
index 63e0348..65c30a7 100644
--- a/docs/source/tutorials/pymovements-in-10-minutes.ipynb
+++ b/docs/source/tutorials/pymovements-in-10-minutes.ipynb
@@ -198,7 +198,7 @@
    "id": "d58e7e7e",
    "metadata": {},
    "source": [
-    "Apart from the familiar columns from the `fileinfo` dataframe we see the columns `time`, `x_right_pix` and `y_right_pix`.\n",
+    "Apart from some trial identifier columns we see the columns `time` and `pixel`.\n",
     "\n",
     "The last two columns refer to the pixel coordinates at the timestep specified by `time`.\n",
     "\n",
@@ -259,8 +259,8 @@
    "id": "a96ddce5",
    "metadata": {},
    "source": [
-    "We notice that two new columns have appeared: `x_right_pos` and `y_right_pos`.\n",
-    "These are the positional columns specified in degrees of visual angle (dva)."
+    "We notice that a new column has appeared: `position`.\n",
+    "This column specifies the position coordinates in degrees of visual angle (dva)."
    ]
   },
   {
@@ -280,7 +280,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "dataset.pos2vel(method='savitzky_golay', window_length=7, polyorder=2)\n",
+    "dataset.pos2vel(method='savitzky_golay', degree=2, window_length=7)\n",
     "\n",
     "dataset.gaze[0].frame.head()"
    ]
diff --git a/src/pymovements/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index 0fd66e6..6089446 100644
--- a/src/pymovements/dataset/dataset.py
+++ b/src/pymovements/dataset/dataset.py
@@ -269,10 +269,10 @@ class Dataset:
 
     def pos2acc(
             self,
-            window_length: int = 7,
+            *,
             degree: int = 2,
-            mode: str = 'interp',
-            cval: float = 0.0,
+            window_length: int = 7,
+            padding: str | float | int | None = 'nearest',
             verbose: bool = True,
     ) -> Dataset:
         """Compute gaze accelerations in dva/s^2 from dva coordinates.
@@ -287,10 +287,8 @@ class Dataset:
             The window size to use.
         degree:
             The degree of the polynomial to use.
-        mode:
-            The padding mode to use.
-        cval:
-            A constant value for padding.
+        padding:
+            The padding method to use. See ``savitzky_golay`` for details.
         verbose : bool
             If True, show progress of computation.
 
@@ -312,13 +310,18 @@ class Dataset:
             gaze_df.pos2acc(
                 window_length=window_length,
                 degree=degree,
-                mode=mode,
-                cval=cval,
+                padding=padding,
             )
 
         return self
 
-    def pos2vel(self, method: str = 'smooth', verbose: bool = True, **kwargs: Any) -> Dataset:
+    def pos2vel(
+            self,
+            method: str = 'fivepoint',
+            *,
+            verbose: bool = True,
+            **kwargs: Any,
+    ) -> Dataset:
         """Compute gaze velocites in dva/s from dva coordinates.
 
         This method requires a properly initialized :py:attr:`~.Dataset.experiment` attribute.
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index 90c9966..1bcc0ed 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -28,7 +28,6 @@ import polars as pl
 
 from pymovements.gaze import transforms
 from pymovements.gaze.experiment import Experiment
-from pymovements.gaze.transforms_numpy import pos2acc
 
 
 class GazeDataFrame:
@@ -270,10 +269,8 @@ class GazeDataFrame:
                 kwargs['sampling_rate'] = self.experiment.sampling_rate
 
             if 'n_components' in method_kwargs and 'n_components' not in kwargs:
-                try:
-                    kwargs['n_components'] = self.frame['pixel'].list.lengths()[0]
-                except pl.ColumnNotFoundError:
-                    kwargs['n_components'] = self.frame['position'].list.lengths()[0]
+                _check_n_components(self.n_components)
+                kwargs['n_components'] = self.n_components
 
             self.frame = self.frame.with_columns(transform_method(**kwargs))
 
@@ -290,51 +287,14 @@ class GazeDataFrame:
             If `gaze` is None or there are no gaze dataframes present in the `gaze` attribute, or
             if experiment is None.
         """
-        self._check_experiment()
-        # mypy does not get that experiment now cannot be None anymore
-        assert self.experiment is not None
-
-        # this is just a work-around until GazeDataFrame.transform() is implemented
-        if 'pixel' in self.frame.columns:
-            pixel_columns = [
-                '__x_left_pix__', '__y_left_pix__',
-                '__x_right_pix__', '__y_right_pix__',
-                '__x_avg_pix__', '__y_avg_pix__',
-            ][:self.n_components]
-            self.unnest('pixel', pixel_columns)
-        else:
-            raise pl.exceptions.ColumnNotFoundError(
-                f'Column \'pixel\' not found. Available columns are: {self.frame.columns}',
-            )
-
-        pixel_positions = self.frame.select(pixel_columns)
-        dva_positions = self.experiment.screen.pix2deg(pixel_positions.to_numpy())
-
-        dva_columns = self._pixel_to_dva_position_columns(pixel_columns)
-        self.frame = self.frame.with_columns(
-            [
-                pl.Series(name=dva_column_name, values=dva_positions[:, dva_column_id])
-                for dva_column_id, dva_column_name in enumerate(dva_columns)
-            ],
-        )
-
-        self.nest(
-            input_columns=dva_columns,
-            output_column='position',
-        )
-
-        # this is just a work-around until merged columns are standard behavior
-        self.nest(
-            input_columns=pixel_columns,
-            output_column='pixel',
-        )
+        self.transform('pix2deg')
 
     def pos2acc(
             self,
-            window_length: int = 7,
+            *,
             degree: int = 2,
-            mode: str = 'interp',
-            cval: float = 0.0,
+            window_length: int = 7,
+            padding: str | float | int | None = 'nearest',
     ) -> None:
         """Compute gaze acceleration in dva/s^2 from dva position coordinates.
 
@@ -348,10 +308,8 @@ class GazeDataFrame:
             The window size to use.
         degree:
             The degree of the polynomial to use.
-        mode:
-            The padding mode to use.
-        cval:
-            A constant value for padding.
+        padding:
+            The padding method to use. See ``savitzky_golay`` for details.
 
         Raises
         ------
@@ -359,53 +317,13 @@ class GazeDataFrame:
             If `gaze` is None or there are no gaze dataframes present in the `gaze` attribute, or
             if experiment is None.
         """
-        self._check_experiment()
-        # mypy does not get that experiment now cannot be None anymore
-        assert self.experiment is not None
-
-        # this is just a work-around until merged columns are standard behavior
-        if 'position' in self.frame.columns:
-            position_columns = [
-                '__x_left_pos__', '__y_left_pos__',
-                '__x_right_pos__', '__y_right_pos__',
-                '__x_avg_pos__', '__y_avg_pos__',
-            ][:self.n_components]
-            self.unnest('position', position_columns)
-        else:
-            raise pl.exceptions.ColumnNotFoundError(
-                f'Column \'position\' not found. Available columns are: {self.frame.columns}',
-            )
-
-        positions = self.frame.select(position_columns)
-        acceleration = pos2acc(
-            positions.to_numpy(),
-            sampling_rate=self.experiment.sampling_rate,
-            window_length=window_length,
-            degree=degree,
-            mode=mode,
-            cval=cval,
-        )
-
-        acceleration_columns = self._position_to_acceleration_columns(position_columns)
-        self.frame = self.frame.with_columns(
-            [
-                pl.Series(name=velocity_column_name, values=acceleration[:, column_id])
-                for column_id, velocity_column_name in enumerate(acceleration_columns)
-            ],
-        )
-
-        self.nest(
-            input_columns=acceleration_columns,
-            output_column='acceleration',
-        )
-
-        # this is just a work-around until merged columns are standard behavior
-        self.nest(
-            input_columns=position_columns,
-            output_column='position',
-        )
+        self.transform('pos2acc', window_length=window_length, degree=degree, padding=padding)
 
-    def pos2vel(self, method: str = 'smooth', **kwargs: int | float | str) -> None:
+    def pos2vel(
+            self,
+            method: str = 'fivepoint',
+            **kwargs: int | float | str,
+    ) -> None:
         """Compute gaze velocity in dva/s from dva position coordinates.
 
         This method requires a properly initialized :py:attr:`~.GazeDataFrame.experiment` attribute.
@@ -415,7 +333,7 @@ class GazeDataFrame:
         Parameters
         ----------
         method : str
-            Computation method. See :func:`~transforms.pos2vel()` for details, default: smooth.
+            Computation method. See :func:`~transforms.pos2vel()` for details, default: fivepoint.
         **kwargs
             Additional keyword arguments to be passed to the :func:`~transforms.pos2vel()` method.
 
@@ -425,44 +343,7 @@ class GazeDataFrame:
             If `gaze` is None or there are no gaze dataframes present in the `gaze` attribute, or
             if experiment is None.
         """
-        self._check_experiment()
-        # mypy does not get that experiment now cannot be None anymore
-        assert self.experiment is not None
-
-        # this is just a work-around until merged columns are standard behavior
-        if 'position' in self.frame.columns:
-            position_columns = [
-                '__x_left_pos__', '__y_left_pos__',
-                '__x_right_pos__', '__y_right_pos__',
-                '__x_avg_pos__', '__y_avg_pos__',
-            ][:self.n_components]
-            self.unnest('position', position_columns)
-        else:
-            raise pl.exceptions.ColumnNotFoundError(
-                f'Column \'position\' not found. Available columns are: {self.frame.columns}',
-            )
-
-        positions = self.frame.select(position_columns)
-        velocities = self.experiment.pos2vel(positions.to_numpy(), method=method, **kwargs)
-
-        velocity_columns = self._position_to_velocity_columns(position_columns)
-        self.frame = self.frame.with_columns(
-            [
-                pl.Series(name=velocity_column_name, values=velocities[:, column_id])
-                for column_id, velocity_column_name in enumerate(velocity_columns)
-            ],
-        )
-
-        self.nest(
-            input_columns=velocity_columns,
-            output_column='velocity',
-        )
-
-        # this is just a work-around until merged columns are standard behavior
-        self.nest(
-            input_columns=position_columns,
-            output_column='position',
-        )
+        self.transform('pos2vel', method=method, **kwargs)
 
     @property
     def schema(self) -> pl.type_aliases.SchemaDict:
@@ -518,30 +399,6 @@ class GazeDataFrame:
             ],
         ).drop(column)
 
-    @staticmethod
-    def _pixel_to_dva_position_columns(columns: list[str]) -> list[str]:
-        """Get corresponding dva position columns from pixel position columns."""
-        return [
-            column.replace('pixel', 'position').replace('pix', 'pos')
-            for column in columns if 'pix' in column
-        ]
-
-    @staticmethod
-    def _position_to_acceleration_columns(columns: list[str]) -> list[str]:
-        """Get corresponding acceleration columns from dva position columns."""
-        return [
-            column.replace('position', 'acceleration').replace('pos', 'acc')
-            for column in columns if 'pos' in column
-        ]
-
-    @staticmethod
-    def _position_to_velocity_columns(columns: list[str]) -> list[str]:
-        """Get corresponding velocity columns from dva position columns."""
-        return [
-            column.replace('position', 'velocity').replace('pos', 'vel')
-            for column in columns if 'pos' in column
-        ]
-
     def _check_experiment(self) -> None:
         """Check if experiment attribute has been set."""
         if self.experiment is None:
@@ -582,3 +439,9 @@ def _check_component_columns(
             raise ValueError(
                 f'all columns in {component_type} must be of same type, but types are {types_list}',
             )
+
+
+def _check_n_components(n_components: Any) -> None:
+    """Check that n_components is either 2, 4 or 6."""
+    if n_components not in {2, 4, 6}:
+        raise AttributeError(f'n_components must be either 2, 4 or 6 but is {n_components}')
diff --git a/src/pymovements/gaze/transforms.py b/src/pymovements/gaze/transforms.py
index 7196fed..5fc61ff 100644
--- a/src/pymovements/gaze/transforms.py
+++ b/src/pymovements/gaze/transforms.py
@@ -295,7 +295,7 @@ def pos2acc(
     window_length:
         The window size to use.
     padding:
-        The padding to use.
+        The padding method to use. See ``savitzky_golay`` for details.
     n_components:
         Number of components in input column.
     position_column:
@@ -534,17 +534,11 @@ def savitzky_golay(
         cval=constant_value,
     )
 
-    # If the sequence is empty, don't use apply but forward sequence.
-    return pl.when(pl.all().len() == 0).then(pl.all()).otherwise(
-        # Use explode to transform array to pl.Series
-        # pl.apply(input_column, partial(helper, func=func)).list.explode(),
-        pl.concat_list(
-            [
-                # pl.map([pl.col(input_column).list.get(component)], func)
-                pl.col(input_column).list.get(component).map(func).list.explode()
-                for component in range(n_components)
-            ],
-        ),
+    return pl.concat_list(
+        [
+            pl.col(input_column).list.get(component).map(func).list.explode()
+            for component in range(n_components)
+        ],
     ).alias(output_column)
 
 


Test patch:
diff --git a/tests/dataset/dataset_test.py b/tests/dataset/dataset_test.py
index 126d319..d1b7c58 100644
--- a/tests/dataset/dataset_test.py
+++ b/tests/dataset/dataset_test.py
@@ -768,7 +768,7 @@ def test_detect_events_attribute_error(dataset_configuration):
             },
             (
                 "Column 'position' not found. Available columns are: "
-                "['subject_id', 'time', 'pixel', 'velocity', 'custom_position']"
+                "['subject_id', 'time', 'pixel', 'custom_position', 'velocity']"
             ),
             id='no_position',
         ),
@@ -781,7 +781,7 @@ def test_detect_events_attribute_error(dataset_configuration):
             },
             (
                 "Column 'velocity' not found. Available columns are: "
-                "['subject_id', 'time', 'pixel', 'custom_velocity', 'position']"
+                "['subject_id', 'time', 'pixel', 'position', 'custom_velocity']"
             ),
             id='no_velocity',
         ),
diff --git a/tests/gaze/gaze_dataframe_test.py b/tests/gaze/gaze_dataframe_test.py
index 73d4039..1bb82c6 100644
--- a/tests/gaze/gaze_dataframe_test.py
+++ b/tests/gaze/gaze_dataframe_test.py
@@ -18,19 +18,12 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """Test all GazeDataFrame functionality."""
-import numpy as np
 import polars as pl
 import pytest
 
-from pymovements.gaze.experiment import Experiment
 from pymovements.gaze.gaze_dataframe import GazeDataFrame
 
 
-@pytest.fixture(name='experiment_fixture')
-def fixture_experiment():
-    return Experiment(1024, 768, 38, 30, 60, 'center', 1000)
-
-
 @pytest.mark.parametrize(
     ('init_arg'),
     [
@@ -170,262 +163,3 @@ def test_gaze_dataframe_position_columns(init_df, position_columns):
     gaze_df = GazeDataFrame(init_df, position_columns=position_columns)
 
     assert 'position' in gaze_df.columns
-
-
-@pytest.mark.parametrize(
-    ('init_df', 'pixel_columns'),
-    [
-        pytest.param(
-            pl.DataFrame(schema={'x_pix': pl.Float64, 'y_pix': pl.Float64}),
-            ['x_pix', 'y_pix'],
-            id='no_eye_pos_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(schema={'abc': pl.Int64, 'x_pix': pl.Float64, 'y_pix': pl.Float64}),
-            ['x_pix', 'y_pix'],
-            id='no_eye_pos_columns_with_other_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(schema={'x_right_pix': pl.Float64, 'y_right_pix': pl.Float64}),
-            ['x_right_pix', 'y_right_pix'],
-            id='right_eye_pos_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(schema={'x_left_pix': pl.Float64, 'y_left_pix': pl.Float64}),
-            ['x_left_pix', 'y_left_pix'],
-            id='left_eye_pos_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                schema={
-                    'x_left_pix': pl.Float64, 'y_left_pix': pl.Float64,
-                    'x_right_pix': pl.Float64, 'y_right_pix': pl.Float64,
-                },
-            ),
-            ['x_left_pix', 'y_left_pix', 'x_right_pix', 'y_right_pix'],
-            id='both_eyes_pos_columns',
-        ),
-    ],
-)
-def test_gaze_dataframe_pix2deg_has_correct_columns(
-        init_df, pixel_columns, experiment_fixture,
-):
-    gaze_df = GazeDataFrame(init_df, experiment=experiment_fixture, pixel_columns=pixel_columns)
-    gaze_df.pix2deg()
-
-    assert 'position' in gaze_df.columns
-
-
-@pytest.mark.parametrize(
-    ('init_kwargs', 'exception', 'expected_msg'),
-    [
-        pytest.param(
-            {
-                'data': pl.DataFrame(schema={'x_foo': pl.Float64, 'y_foo': pl.Float64}),
-                'experiment': Experiment(1024, 768, 38, 30, 60, 'center', 1000),
-            },
-            pl.exceptions.ColumnNotFoundError,
-            'Column \'pixel\' not found. Available columns are: [\'x_foo\', \'y_foo\']',
-            id='no_pixel_column',
-        ),
-        pytest.param(
-            {'data': pl.DataFrame(schema={'x_pix': pl.Float64, 'y_pix': pl.Float64})},
-            AttributeError,
-            'experiment must not be None for this method to work',
-            id='no_experiment',
-        ),
-    ],
-)
-def test_gaze_dataframe_pix2deg_exceptions(init_kwargs, exception, expected_msg):
-    gaze_df = GazeDataFrame(**init_kwargs)
-
-    with pytest.raises(exception) as excinfo:
-        gaze_df.pix2deg()
-
-    msg, = excinfo.value.args
-    assert msg == expected_msg
-
-
-@pytest.mark.parametrize(
-    ('init_df', 'pixel_columns'),
-    [
-        pytest.param(
-            pl.DataFrame(
-                {'x_pix': np.arange(100), 'y_pix': np.arange(100)},
-                schema={'x_pix': pl.Float64, 'y_pix': pl.Float64},
-            ),
-            ['x_pix', 'y_pix'],
-            id='no_eye_pos_acc_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {'abc': np.arange(100), 'x_pix': np.arange(100), 'y_pix': np.arange(100)},
-                schema={'abc': pl.Int64, 'x_pix': pl.Float64, 'y_pix': pl.Float64},
-            ),
-            ['x_pix', 'y_pix'],
-            id='no_eye_pos_acc_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {'x_right_pix': np.arange(100), 'y_right_pix': np.arange(100)},
-                schema={'x_right_pix': pl.Float64, 'y_right_pix': pl.Float64},
-            ),
-            ['x_right_pix', 'y_right_pix'],
-            id='right_eye_pos_acc_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {'x_left_pix': np.arange(100), 'y_left_pix': np.arange(100)},
-                schema={'x_left_pix': pl.Float64, 'y_left_pix': pl.Float64},
-            ),
-            ['x_left_pix', 'y_left_pix'],
-            id='left_eye_pos_acc_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {
-                    'x_left_pix': np.arange(100), 'y_left_pix': np.arange(100),
-                    'x_right_pix': np.arange(100), 'y_right_pix': np.arange(100),
-                },
-                schema={
-                    'x_left_pix': pl.Float64, 'y_left_pix': pl.Float64,
-                    'x_right_pix': pl.Float64, 'y_right_pix': pl.Float64,
-                },
-            ),
-            [
-                'x_left_pix', 'y_left_pix', 'x_right_pix', 'y_right_pix',
-            ],
-            id='both_eyes_pos_acc_columns',
-        ),
-    ],
-)
-def test_gaze_dataframe_pos2acc_has_correct_columns(
-        init_df, pixel_columns, experiment_fixture,
-):
-    gaze_df = GazeDataFrame(init_df, experiment=experiment_fixture, pixel_columns=pixel_columns)
-    gaze_df.pix2deg()
-    gaze_df.pos2acc()
-
-    assert 'acceleration' in gaze_df.columns
-
-
-@pytest.mark.parametrize(
-    ('init_kwargs', 'exception', 'expected_msg'),
-    [
-        pytest.param(
-            {
-                'data': pl.DataFrame(schema={'x_pos': pl.Float64, 'y_pos': pl.Float64}),
-                'experiment': Experiment(1024, 768, 38, 30, 60, 'center', 1000),
-            },
-            pl.exceptions.ColumnNotFoundError,
-            "Column 'position' not found. Available columns are: ['x_pos', 'y_pos']",
-            id='no_pixel_column',
-        ),
-        pytest.param(
-            {'data': pl.DataFrame(schema={'x_pos': pl.Float64, 'y_pos': pl.Float64})},
-            AttributeError,
-            'experiment must not be None for this method to work',
-            id='no_experiment',
-        ),
-    ],
-)
-def test_gaze_dataframe_pos2acc_exceptions(init_kwargs, exception, expected_msg):
-    gaze_df = GazeDataFrame(**init_kwargs)
-
-    with pytest.raises(exception) as excinfo:
-        gaze_df.pos2acc()
-
-    msg, = excinfo.value.args
-    assert msg == expected_msg
-
-
-@pytest.mark.parametrize(
-    ('init_df', 'pixel_columns'),
-    [
-        pytest.param(
-            pl.DataFrame(
-                {'x_pix': np.arange(100), 'y_pix': np.arange(100)},
-                schema={'x_pix': pl.Float64, 'y_pix': pl.Float64},
-            ),
-            ['x_pix', 'y_pix'],
-            id='no_eye_pos_vel_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {'abc': np.arange(100), 'x_pix': np.arange(100), 'y_pix': np.arange(100)},
-                schema={'abc': pl.Int64, 'x_pix': pl.Float64, 'y_pix': pl.Float64},
-            ),
-            ['x_pix', 'y_pix'],
-            id='no_eye_pos_vel_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {'x_right_pix': np.arange(100), 'y_right_pix': np.arange(100)},
-                schema={'x_right_pix': pl.Float64, 'y_right_pix': pl.Float64},
-            ),
-            ['x_right_pix', 'y_right_pix'],
-            id='right_eye_pos_vel_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {'x_left_pix': np.arange(100), 'y_left_pix': np.arange(100)},
-                schema={'x_left_pix': pl.Float64, 'y_left_pix': pl.Float64},
-            ),
-            ['x_left_pix', 'y_left_pix'],
-            id='left_eye_pos_vel_columns',
-        ),
-        pytest.param(
-            pl.DataFrame(
-                {
-                    'x_left_pix': np.arange(100), 'y_left_pix': np.arange(100),
-                    'x_right_pix': np.arange(100), 'y_right_pix': np.arange(100),
-                },
-                schema={
-                    'x_left_pix': pl.Float64, 'y_left_pix': pl.Float64,
-                    'x_right_pix': pl.Float64, 'y_right_pix': pl.Float64,
-                },
-            ),
-            [
-                'x_left_pix', 'y_left_pix', 'x_right_pix', 'y_right_pix',
-            ],
-            id='both_eyes_pos_vel_columns',
-        ),
-    ],
-)
-def test_gaze_dataframe_pos2vel_has_correct_columns(
-        init_df, pixel_columns, experiment_fixture,
-):
-    gaze_df = GazeDataFrame(init_df, experiment=experiment_fixture, pixel_columns=pixel_columns)
-    gaze_df.pix2deg()
-    gaze_df.pos2vel()
-
-    assert 'velocity' in gaze_df.columns
-
-
-@pytest.mark.parametrize(
-    ('init_kwargs', 'exception', 'expected_msg'),
-    [
-        pytest.param(
-            {
-                'data': pl.DataFrame(schema={'x_pos': pl.Float64, 'y_pos': pl.Float64}),
-                'experiment': Experiment(1024, 768, 38, 30, 60, 'center', 1000),
-            },
-            pl.exceptions.ColumnNotFoundError,
-            "Column 'position' not found. Available columns are: ['x_pos', 'y_pos']",
-        ),
-        pytest.param(
-            {'data': pl.DataFrame(schema={'x_pos': pl.Float64, 'y_pos': pl.Float64})},
-            AttributeError,
-            'experiment must not be None for this method to work',
-            id='no_dva_pos_columns',
-        ),
-    ],
-)
-def test_gaze_dataframe_pos2vel_exceptions(init_kwargs, exception, expected_msg):
-    gaze_df = GazeDataFrame(**init_kwargs)
-
-    with pytest.raises(exception) as excinfo:
-        gaze_df.pos2vel()
-
-    msg, = excinfo.value.args
-    assert msg == expected_msg
diff --git a/tests/gaze/gaze_dataframe_init_test.py b/tests/gaze/gaze_init_test.py
similarity index 100%
rename from tests/gaze/gaze_dataframe_init_test.py
rename to tests/gaze/gaze_init_test.py
diff --git a/tests/gaze/gaze_transform_test.py b/tests/gaze/gaze_transform_test.py
index e7e40f0..bcd52ac 100644
--- a/tests/gaze/gaze_transform_test.py
+++ b/tests/gaze/gaze_transform_test.py
@@ -26,6 +26,11 @@ from polars.testing import assert_frame_equal
 import pymovements as pm
 
 
+@pytest.fixture(name='experiment')
+def fixture_experiment():
+    return pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000)
+
+
 @pytest.mark.parametrize(
     ('gaze_init_kwargs', 'transform_method', 'transform_kwargs', 'expected'),
     [
@@ -426,3 +431,246 @@ def test_gaze_transform_raises_exception(init_kwargs, exception, exception_msg):
 
     msg, = excinfo.value.args
     assert msg == exception_msg
+
+
+@pytest.mark.parametrize(
+    ('data', 'pixel_columns'),
+    [
+        pytest.param(
+            {'x': [0.0], 'y': [0.0]},
+            ['x', 'y'],
+            id='two_components',
+        ),
+        pytest.param(
+            {'t': [0], 'x': [0.0], 'y': [0.0]},
+            ['x', 'y'],
+            id='two_components_and_time',
+        ),
+        pytest.param(
+            {'xl': [0.0], 'yl': [0.0], 'xr': [0.0], 'yr': [0.0]},
+            ['xl', 'yl', 'xr', 'yr'],
+            id='four_components',
+        ),
+        pytest.param(
+            {'xl': [0.0], 'yl': [0.0], 'xr': [0.0], 'yr': [0.0], 'xa': [0.0], 'ya': [0.0]},
+            ['xl', 'yl', 'xr', 'yr', 'xa', 'ya'],
+            id='six_components',
+        ),
+    ],
+)
+def test_gaze_dataframe_pix2deg_creates_position_column(data, experiment, pixel_columns):
+    gaze = pm.GazeDataFrame(
+        data=pl.from_dict(data),
+        experiment=experiment,
+        pixel_columns=pixel_columns,
+    )
+    gaze.pix2deg()
+    assert 'position' in gaze.columns
+
+
+@pytest.mark.parametrize(
+    ('init_kwargs', 'exception', 'expected_msg'),
+    [
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+            },
+            AttributeError,
+            'n_components must be either 2, 4 or 6 but is None',
+            id='no_column_components',
+        ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+                'acceleration_columns': ['x', 'y'],
+            },
+            pl.exceptions.ColumnNotFoundError,
+            (
+                'pixel\n\nError originated just after this operation:\nDF ["acceleration"]; '
+                'PROJECT */1 COLUMNS; SELECTION: "None"'
+            ),
+            id='no_pixel_column',
+        ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'pixel_columns': ['x', 'y'],
+            },
+            AttributeError,
+            'experiment must not be None for this method to work',
+            id='no_experiment',
+        ),
+    ],
+)
+def test_gaze_dataframe_pix2deg_exceptions(init_kwargs, exception, expected_msg):
+    gaze_df = pm.GazeDataFrame(**init_kwargs)
+
+    with pytest.raises(exception) as excinfo:
+        gaze_df.pix2deg()
+
+    msg, = excinfo.value.args
+    assert msg == expected_msg
+
+
+@pytest.mark.parametrize(
+    ('data', 'position_columns'),
+    [
+        pytest.param(
+            {'x': [0.0], 'y': [0.0]},
+            ['x', 'y'],
+            id='two_components',
+        ),
+        pytest.param(
+            {'t': [0], 'x': [0.0], 'y': [0.0]},
+            ['x', 'y'],
+            id='two_components_and_time',
+        ),
+        pytest.param(
+            {'xl': [0.0], 'yl': [0.0], 'xr': [0.0], 'yr': [0.0]},
+            ['xl', 'yl', 'xr', 'yr'],
+            id='four_components',
+        ),
+        pytest.param(
+            {'xl': [0.0], 'yl': [0.0], 'xr': [0.0], 'yr': [0.0], 'xa': [0.0], 'ya': [0.0]},
+            ['xl', 'yl', 'xr', 'yr', 'xa', 'ya'],
+            id='six_components',
+        ),
+    ],
+)
+def test_gaze_dataframe_pos2acc_creates_acceleration_column(data, experiment, position_columns):
+    gaze = pm.GazeDataFrame(
+        data=pl.from_dict(data),
+        experiment=experiment,
+        position_columns=position_columns,
+    )
+    gaze.pos2acc()
+    assert 'acceleration' in gaze.columns
+
+
+@pytest.mark.parametrize(
+    ('init_kwargs', 'exception', 'expected_msg'),
+    [
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+            },
+            AttributeError,
+            'n_components must be either 2, 4 or 6 but is None',
+            id='no_column_components',
+        ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+                'pixel_columns': ['x', 'y'],
+            },
+            pl.exceptions.ColumnNotFoundError,
+            (
+                'position\n\nError originated just after this operation:\nDF ["pixel"]; '
+                'PROJECT */1 COLUMNS; SELECTION: "None"'
+            ),
+            id='no_position_column',
+        ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'position_columns': ['x', 'y'],
+            },
+            AttributeError,
+            'experiment must not be None for this method to work',
+            id='no_experiment',
+        ),
+    ],
+)
+def test_gaze_dataframe_pos2acc_exceptions(init_kwargs, exception, expected_msg):
+    gaze_df = pm.GazeDataFrame(**init_kwargs)
+
+    with pytest.raises(exception) as excinfo:
+        gaze_df.pos2acc()
+
+    msg, = excinfo.value.args
+    assert msg == expected_msg
+
+
+@pytest.mark.parametrize(
+    ('data', 'position_columns'),
+    [
+        pytest.param(
+            {'x': [0.0], 'y': [0.0]},
+            ['x', 'y'],
+            id='two_components',
+        ),
+        pytest.param(
+            {'t': [0], 'x': [0.0], 'y': [0.0]},
+            ['x', 'y'],
+            id='two_components_and_time',
+        ),
+        pytest.param(
+            {'xl': [0.0], 'yl': [0.0], 'xr': [0.0], 'yr': [0.0]},
+            ['xl', 'yl', 'xr', 'yr'],
+            id='four_components',
+        ),
+        pytest.param(
+            {'xl': [0.0], 'yl': [0.0], 'xr': [0.0], 'yr': [0.0], 'xa': [0.0], 'ya': [0.0]},
+            ['xl', 'yl', 'xr', 'yr', 'xa', 'ya'],
+            id='six_components',
+        ),
+    ],
+)
+def test_gaze_dataframe_pos2vel_creates_velocity_column(data, experiment, position_columns):
+    gaze = pm.GazeDataFrame(
+        data=pl.from_dict(data),
+        experiment=experiment,
+        position_columns=position_columns,
+    )
+    gaze.pos2vel(method='savitzky_golay', window_length=7, degree=2)
+    assert 'velocity' in gaze.columns
+
+
+@pytest.mark.parametrize(
+    ('init_kwargs', 'exception', 'expected_msg'),
+    [
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+            },
+            AttributeError,
+            'n_components must be either 2, 4 or 6 but is None',
+            id='no_column_components',
+        ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+                'pixel_columns': ['x', 'y'],
+            },
+            pl.exceptions.ColumnNotFoundError,
+            (
+                'position\n\nError originated just after this operation:\nDF ["pixel"]; '
+                'PROJECT */1 COLUMNS; SELECTION: "None"'
+            ),
+            id='no_position_column',
+        ),
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
+                'position_columns': ['x', 'y'],
+            },
+            AttributeError,
+            'experiment must not be None for this method to work',
+            id='no_experiment',
+        ),
+    ],
+)
+def test_gaze_dataframe_pos2vel_exceptions(init_kwargs, exception, expected_msg):
+    gaze_df = pm.GazeDataFrame(**init_kwargs)
+
+    with pytest.raises(exception) as excinfo:
+        gaze_df.pos2vel()
+
+    msg, = excinfo.value.args
+    assert msg == expected_msg
diff --git a/tests/gaze/transforms/pos2acc_test.py b/tests/gaze/transforms/pos2acc_test.py
index 67eb98d..a7f47ae 100644
--- a/tests/gaze/transforms/pos2acc_test.py
+++ b/tests/gaze/transforms/pos2acc_test.py
@@ -119,6 +119,7 @@ def test_pos2acc_raises_error(kwargs, series, exception, msg_substrings):
             pl.Series('position', [], pl.List(pl.Float64)),
             pl.Series('acceleration', [], pl.List(pl.Float64)),
             id='empty_series_returns_empty_series',
+            marks=pytest.mark.xfail(reason='#475'),
         ),
         pytest.param(
             {'window_length': 3, 'degree': 1, 'sampling_rate': 1, 'n_components': 2},
diff --git a/tests/gaze/transforms/pos2vel_test.py b/tests/gaze/transforms/pos2vel_test.py
index 945d023..6f30f3a 100644
--- a/tests/gaze/transforms/pos2vel_test.py
+++ b/tests/gaze/transforms/pos2vel_test.py
@@ -165,6 +165,7 @@ def test_pos2vel_raises_error(kwargs, series, exception, msg_substrings):
             pl.Series('position', [], pl.List(pl.Float64)),
             pl.Series('velocity', [], pl.List(pl.Float64)),
             id='empty_series_raises_compute_error',
+            marks=pytest.mark.xfail(reason='#475'),
         ),
         pytest.param(
             {
diff --git a/tests/gaze/transforms/savitzky_golay_test.py b/tests/gaze/transforms/savitzky_golay_test.py
index ab236b9..c6cb4e7 100644
--- a/tests/gaze/transforms/savitzky_golay_test.py
+++ b/tests/gaze/transforms/savitzky_golay_test.py
@@ -172,6 +172,7 @@ def test_savitzky_golay_raises_error(kwargs, series, exception, msg_substrings):
             pl.Series('A', [], pl.List(pl.Float64)),
             pl.Series('A', [], pl.List(pl.Float64)),
             id='empty_series_returns_empty_series',
+            marks=pytest.mark.xfail(reason='#475'),
         ),
         pytest.param(
             {
diff --git a/tests/gaze/transforms_numpy_test.py b/tests/gaze/transforms_numpy_test.py
index 40c4148..4884dc7 100644
--- a/tests/gaze/transforms_numpy_test.py
+++ b/tests/gaze/transforms_numpy_test.py
@@ -434,6 +434,17 @@ def test_pix2deg_raises_error(kwargs, expected_error):
             np.array([[0.0, 0.0, 0.0, 0.0]] * n_coords),
             id='nparray_of_zero_coords_4d',
         ),
+        pytest.param(
+            {
+                'arr': np.array([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]] * n_coords),
+                'screen_px': screen_px_2d,
+                'screen_cm': screen_cm_2d,
+                'distance_cm': screen_cm_1d,
+                'origin': 'center',
+            },
+            np.array([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]] * n_coords),
+            id='nparray_of_zero_coords_6d',
+        ),
     ],
 )
 def test_pix2deg_returns(kwargs, expected_value):