Repository: aeye-lab/pymovements
Pull request #479 (2023-08-28 07:57:36)

feat: Add suffixes argument to GazeDataFrame.unnest()
## Description

Please include a clear and concise description of the change and which issue is fixed.
Please also include excepted improvements, relevant motivation and context.
List any dependencies that are required for this change.

Fixes issue #

## Implemented changes

Insert a description of the changes implemented in the pull request.

- [ ] Change 1
- [ ] Change 2
- [ ] Change 3

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change is or requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes.
Provide instructions so we can reproduce.
Please also list any relevant details for your test configuration

- [ ] Test A
- [ ] Test B

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
- [ ] I have checked my code and corrected any misspellings


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/479?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`94.11%`** and project coverage change: **`-3.53%`** :warning:
> Comparison is base [(`2a6cc39`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/2a6cc398688cfeb25107d4cb2c7444cc90b69b50?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`9cccfc7`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/479?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 96.47%.
> Report is 1 commits behind head on main.

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


```diff
@@             Coverage Diff             @@
##              main     #479      +/-   ##
===========================================
- Coverage   100.00%   96.47%   -3.53%     
===========================================
  Files           48       48              
  Lines         2086     2097      +11     
  Branches       500      463      -37     
===========================================
- Hits          2086     2023      -63     
- Misses           0       58      +58     
- Partials         0       16      +16     
```


| [Files Changed](https://app.codecov.io/gh/aeye-lab/pymovements/pull/479?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/gaze/gaze\_dataframe.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/479?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZ2F6ZV9kYXRhZnJhbWUucHk=) | `99.21% <94.11%> (-0.79%)` | :arrow_down: |

... and [9 files with indirect coverage changes](https://app.codecov.io/gh/aeye-lab/pymovements/pull/479/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab)


</details>

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

dkrako: Regarding some of the failing tests, these are from the breaking change in the signature of `unnest()`.
Here are three main issues:

1. the `tsplot` tests in `tests/plotting/tsplot_test.py` can be fixed by using the correct keyword argument, so write `gaze.unnest('pixel', output_columns=['x_pix', 'y_pix'])` instead of `gaze.unnest('pixel', ['x_pix', 'y_pix'])`

2. the `save_preprocessed()` function in `dataset/dataset_files.py` needs to be changed accordingly.

This is the current state:
 https://github.com/aeye-lab/pymovements/blob/4af4fc109b401458f8c7f2bf09119d0f6bfc0504/src/pymovements/dataset/dataset_files.py#L489-L527

But thanks to your work you can remove most of the stuff here, and reduce the entries to two lines each, e.g.:

```python
if 'pixel' in gaze_df.frame.columns:
    gaze_df.unnest('pixel')
```

You don't need to keep the dictionary with `unnested_columns`.
Actually there is some unexpected behaviour here, as calling the funtion modifies the dataframe (by calling `unnest()`).
I will fix this in another issue so you don't need to worry about that.

3. Usage of unnest needs to be adjusted in `detect_events()`.

https://github.com/aeye-lab/pymovements/blob/4af4fc109b401458f8c7f2bf09119d0f6bfc0504/src/pymovements/dataset/dataset.py#L430-L431

and 

https://github.com/aeye-lab/pymovements/blob/4af4fc109b401458f8c7f2bf09119d0f6bfc0504/src/pymovements/dataset/dataset.py#L473-L474

Simply use the `output_columns` keyword argument as in the tests for `tsplot`. I am currently in the process of rewriting that method to use all the new functionality, so don't bother about the rest of the method for now.

Patch:
diff --git a/src/pymovements/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index 727c123..40218e2 100644
--- a/src/pymovements/dataset/dataset.py
+++ b/src/pymovements/dataset/dataset.py
@@ -427,8 +427,8 @@ class Dataset:
                 f' Available columns are: {self.gaze[0].frame.columns}',
             )
 
-        self.gaze[0].unnest('position', unnested_columns['position'])
-        self.gaze[0].unnest('velocity', unnested_columns['velocity'])
+        self.gaze[0].unnest('position', output_columns=unnested_columns['position'])
+        self.gaze[0].unnest('velocity', output_columns=unnested_columns['velocity'])
 
         if (
                 isinstance(self.gaze[0].n_components, int)
@@ -470,8 +470,8 @@ class Dataset:
         ):
             # this is just a work-around until merged columns are standard behavior
             # https://github.com/aeye-lab/pymovements/pull/443
-            gaze_df.unnest('position', unnested_columns['position'])
-            gaze_df.unnest('velocity', unnested_columns['velocity'])
+            gaze_df.unnest('position', output_columns=unnested_columns['position'])
+            gaze_df.unnest('velocity', output_columns=unnested_columns['velocity'])
 
             positions = gaze_df.frame.select(position_columns).to_numpy()
             velocities = gaze_df.frame.select(velocity_columns).to_numpy()
diff --git a/src/pymovements/dataset/dataset_files.py b/src/pymovements/dataset/dataset_files.py
index a76607b..949f726 100644
--- a/src/pymovements/dataset/dataset_files.py
+++ b/src/pymovements/dataset/dataset_files.py
@@ -229,8 +229,6 @@ def load_gaze_files(
             )
 
         elif preprocessed and extension == 'csv':
-            # Workaround for preprocessed column names
-            # https://github.com/aeye-lab/pymovements/pull/443
             time_column = None
             if 'time' in gaze_data.columns:
                 time_column = 'time'
@@ -238,26 +236,15 @@ def load_gaze_files(
             pixel_columns: list[str] = list(
                 set(GazeDataFrame.valid_pixel_position_columns) & set(gaze_data.columns),
             )
-            if not pixel_columns:
-                pixel_columns = None  # type: ignore
-
             position_columns: list[str] = list(
                 set(GazeDataFrame.valid_position_columns) & set(gaze_data.columns),
             )
-            if not position_columns:
-                position_columns = None  # type: ignore
-
             velocity_columns: list[str] = list(
                 set(GazeDataFrame.valid_velocity_columns) & set(gaze_data.columns),
             )
-            if not velocity_columns:
-                velocity_columns = None  # type: ignore
-
             acceleration_columns: list[str] = list(
                 set(GazeDataFrame.valid_acceleration_columns) & set(gaze_data.columns),
             )
-            if not acceleration_columns:
-                acceleration_columns = None  # type: ignore
 
             gaze_df = GazeDataFrame(
                 gaze_data,
@@ -487,44 +474,17 @@ def save_preprocessed(
         )
 
         if extension == 'csv':
-            # this is just a work-around until merged columns are standard behavior
-            # https://github.com/aeye-lab/pymovements/pull/443
-            unnested_columns = {}
             if 'pixel' in gaze_df.frame.columns:
-                unnested_columns_pix = [
-                    'x_left_pix', 'y_left_pix',
-                    'x_right_pix', 'y_right_pix',
-                    'x_avg_pix', 'y_avg_pix',
-                ][:gaze_df.n_components]
-                gaze_df.unnest('pixel', unnested_columns_pix)
-                unnested_columns['pixel'] = unnested_columns_pix
+                gaze_df.unnest('pixel')
 
             if 'position' in gaze_df.frame.columns:
-                unnested_columns_pos = [
-                    'x_left_pos', 'y_left_pos',
-                    'x_right_pos', 'y_right_pos',
-                    'x_avg_pos', 'y_avg_pos',
-                ][:gaze_df.n_components]
-                gaze_df.unnest('position', unnested_columns_pos)
-                unnested_columns['position'] = unnested_columns_pos
+                gaze_df.unnest('position')
 
             if 'velocity' in gaze_df.frame.columns:
-                unnested_columns_vel = [
-                    'x_left_vel', 'y_left_vel',
-                    'x_right_vel', 'y_right_vel',
-                    'x_avg_vel', 'y_avg_vel',
-                ][:gaze_df.n_components]
-                gaze_df.unnest('velocity', unnested_columns_vel)
-                unnested_columns['velocity'] = unnested_columns_vel
+                gaze_df.unnest('velocity')
 
             if 'acceleration' in gaze_df.frame.columns:
-                unnested_columns_vel = [
-                    'x_left_acc', 'y_left_acc',
-                    'x_right_acc', 'y_right_acc',
-                    'x_avg_acc', 'y_avg_acc',
-                ][:gaze_df.n_components]
-                gaze_df.unnest('acceleration', unnested_columns_vel)
-                unnested_columns['acceleration'] = unnested_columns_vel
+                gaze_df.unnest('acceleration')
 
         gaze_df_out = gaze_df.frame.clone()
         for column in gaze_df.columns:
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index 0418525..3440234 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -28,6 +28,7 @@ import polars as pl
 
 from pymovements.gaze import transforms
 from pymovements.gaze.experiment import Experiment
+from pymovements.utils import checks
 
 
 class GazeDataFrame:
@@ -399,7 +400,9 @@ class GazeDataFrame:
     def unnest(
             self,
             column: str,
-            output_columns: list[str],
+            output_suffixes: list[str] | None = None,
+            *,
+            output_columns: list[str] | None = None,
     ) -> None:
         """Explode a column of type ``pl.List`` into one column for each list component.
 
@@ -411,11 +414,50 @@ class GazeDataFrame:
             Name of input columns to be unnested into several component columns.
         output_columns:
             Name of the resulting tuple columns.
+        output_suffixes:
+            Suffixes to append to the column names.
+
+        Raises
+        ------
+        ValueError
+            If both output_columns and output_suffixes are specified.
+            If number of output columns / suffixes does not match number of components.
+            If output columns / suffixes are not unique.
+        AttributeError
+            If number of components is not 2, 4 or 6.
         """
+
+        checks.check_is_mutual_exclusive(
+            output_columns=output_columns,
+            output_suffixes=output_suffixes,
+        )
+        _check_n_components(self.n_components)
+
+        col_names = output_columns if output_columns is not None else []
+
+        if output_columns is None and output_suffixes is None:
+            if self.n_components == 2:
+                output_suffixes = ['_x', '_y']
+            elif self.n_components == 4:
+                output_suffixes = ['_xl', '_yl', '_xr', '_yr']
+            else:  # This must be 6 as we already have checked our n_components.
+                output_suffixes = ['_xl', '_yl', '_xr', '_yr', '_xa', '_ya']
+
+        if output_suffixes:
+            col_names = [f'{column}{suffix}' for suffix in output_suffixes]
+
+        if len(col_names) != self.n_components:
+            raise ValueError(
+                f'Number of output columns / suffixes ({len(col_names)}) '
+                f'must match number of components ({self.n_components})',
+            )
+        if len(set(col_names)) != len(col_names):
+            raise ValueError('Output columns / suffixes must be unique')
+
         self.frame = self.frame.with_columns(
             [
-                pl.col(column).list.get(component_id).alias(output_column)
-                for component_id, output_column in enumerate(output_columns)
+                pl.col(column).list.get(component_id).alias(col_names)
+                for component_id, col_names in enumerate(col_names)
             ],
         ).drop(column)
 


Test patch:
diff --git a/tests/gaze/gaze_dataframe_unnest_test.py b/tests/gaze/gaze_dataframe_unnest_test.py
index f917e19..8060bf7 100644
--- a/tests/gaze/gaze_dataframe_unnest_test.py
+++ b/tests/gaze/gaze_dataframe_unnest_test.py
@@ -26,11 +26,12 @@ import pymovements as pm
 
 
 @pytest.mark.parametrize(
-    ('init_data', 'unnest_kwargs', 'expected'),
+    ('init_data', 'unnest_kwargs', 'n_components', 'expected'),
     [
         pytest.param(
             pl.DataFrame(schema={'pixel': pl.List(pl.Float64)}),
             {'column': 'pixel', 'output_columns': ['x', 'y']},
+            2,
             pl.DataFrame(schema={'x': pl.Float64, 'y': pl.Float64}),
             id='empty_df_with_schema_two_pixel_columns',
         ),
@@ -38,6 +39,7 @@ import pymovements as pm
         pytest.param(
             pl.DataFrame(schema={'abc': pl.Int64, 'pixel': pl.List(pl.Float64)}),
             {'column': 'pixel', 'output_columns': ['x', 'y']},
+            2,
             pl.DataFrame(schema={'abc': pl.Int64, 'x': pl.Float64, 'y': pl.Float64}),
             id='empty_df_with_three_column_schema_two_pixel_columns',
         ),
@@ -45,6 +47,7 @@ import pymovements as pm
         pytest.param(
             pl.DataFrame(schema={'pixel': pl.List(pl.Float64)}),
             {'column': 'pixel', 'output_columns': ['xl', 'yl', 'xr', 'yr']},
+            4,
             pl.DataFrame(
                 schema={
                     'xl': pl.Float64, 'yl': pl.Float64, 'xr': pl.Float64, 'yr': pl.Float64,
@@ -56,6 +59,7 @@ import pymovements as pm
         pytest.param(
             pl.DataFrame(schema={'pixel': pl.List(pl.Float64)}),
             {'column': 'pixel', 'output_columns': ['xl', 'yl', 'xr', 'yr', 'xa', 'ya']},
+            6,
             pl.DataFrame(
                 schema={
                     'xl': pl.Float64, 'yl': pl.Float64,
@@ -69,6 +73,7 @@ import pymovements as pm
         pytest.param(
             pl.DataFrame({'pixel': [[1.23, 4.56]]}),
             {'column': 'pixel', 'output_columns': ['x', 'y']},
+            2,
             pl.DataFrame({'x': [1.23], 'y': [4.56]}),
             id='df_single_row_two_pixel_columns',
         ),
@@ -76,6 +81,7 @@ import pymovements as pm
         pytest.param(
             pl.DataFrame({'abc': [1], 'pixel': [[1.23, 4.56]]}),
             {'column': 'pixel', 'output_columns': ['x', 'y']},
+            2,
             pl.DataFrame({'abc': [1], 'x': [1.23], 'y': [4.56]}),
             id='df_single_row_three_columns_two_pixel_columns',
         ),
@@ -83,6 +89,7 @@ import pymovements as pm
         pytest.param(
             pl.DataFrame({'pixel': [[1.2, 3.4, 5.6, 7.8]]}),
             {'column': 'pixel', 'output_columns': ['xl', 'yl', 'xr', 'yr']},
+            4,
             pl.DataFrame({'xl': [1.2], 'yl': [3.4], 'xr': [5.6], 'yr': [7.8]}),
             id='df_single_row_four_pixel_columns',
         ),
@@ -90,12 +97,217 @@ import pymovements as pm
         pytest.param(
             pl.DataFrame({'pixel': [[0.1, 0.2, 0.3, 0.4, 0.5, 0.6]]}),
             {'column': 'pixel', 'output_columns': ['xl', 'yl', 'xr', 'yr', 'xa', 'ya']},
+            6,
             pl.DataFrame({'xl': [.1], 'yl': [.2], 'xr': [.3], 'yr': [.4], 'xa': [.5], 'ya': [.6]}),
             id='df_single_row_six_pixel_columns',
         ),
     ],
 )
-def test_gaze_dataframe_unnest_has_expected_frame(init_data, unnest_kwargs, expected):
+def test_gaze_dataframe_unnest_has_expected_frame(init_data, unnest_kwargs, n_components, expected):
     gaze = pm.GazeDataFrame(init_data)
+    gaze.n_components = n_components
     gaze.unnest(**unnest_kwargs)
     assert_frame_equal(gaze.frame, expected)
+
+
+@pytest.mark.parametrize(
+    ('init_data', 'unnest_kwargs', 'n_components', 'expected'),
+    [
+        pytest.param(
+            pl.DataFrame(schema={'pixel': pl.List(pl.Float64)}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_y'], 'output_columns': None},
+            2,
+            pl.DataFrame(schema={'pixel_x': pl.Float64, 'pixel_y': pl.Float64}),
+            id='empty_df_with_schema_two_pixel_suffixes_columns_none',
+        ),
+
+        pytest.param(
+            pl.DataFrame(schema={'abc': pl.Int64, 'pixel': pl.List(pl.Float64)}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_y'], 'output_columns': None},
+            2,
+            pl.DataFrame(schema={'abc': pl.Int64, 'pixel_x': pl.Float64, 'pixel_y': pl.Float64}),
+            id='empty_df_with_three_column_schema_two_pixel_suffixes_columns_none',
+        ),
+
+        pytest.param(
+            pl.DataFrame(schema={'pixel': pl.List(pl.Float64)}),
+            {
+                'column': 'pixel', 'output_suffixes': [
+                    '_xl', '_yl', '_xr', '_yr',
+                ], 'output_columns': None,
+            },
+            4,
+            pl.DataFrame(
+                schema={
+                    'pixel_xl': pl.Float64, 'pixel_yl': pl.Float64,
+                    'pixel_xr': pl.Float64, 'pixel_yr': pl.Float64,
+                },
+            ),
+            id='empty_df_with_schema_four_pixel_suffixes_columns_none',
+        ),
+
+        pytest.param(
+            pl.DataFrame(schema={'pixel': pl.List(pl.Float64)}),
+            {'column': 'pixel', 'output_suffixes': ['_xl', '_yl', '_xr', '_yr', '_xa', '_ya']},
+            6,
+            pl.DataFrame(
+                schema={
+                    'pixel_xl': pl.Float64, 'pixel_yl': pl.Float64,
+                    'pixel_xr': pl.Float64, 'pixel_yr': pl.Float64,
+                    'pixel_xa': pl.Float64, 'pixel_ya': pl.Float64,
+                },
+            ),
+            id='empty_df_with_schema_six_pixel_columns',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_y']},
+            2,
+            pl.DataFrame({'pixel_x': [1.23], 'pixel_y': [4.56]}),
+            id='df_single_row_two_pixel_suffixes',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'abc': [1], 'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_y']},
+            2,
+            pl.DataFrame({'abc': [1], 'pixel_x': [1.23], 'pixel_y': [4.56]}),
+            id='df_single_row_three_columns_two_pixel_suffixes',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.2, 3.4, 5.6, 7.8]]}),
+            {'column': 'pixel', 'output_suffixes': ['_xl', '_yl', '_xr', '_yr']},
+            4,
+            pl.DataFrame({
+                'pixel_xl': [1.2], 'pixel_yl': [3.4],
+                'pixel_xr': [5.6], 'pixel_yr': [7.8],
+            }),
+            id='df_single_row_four_pixel_suffixes',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'pixel': [[0.1, 0.2, 0.3, 0.4, 0.5, 0.6]]}),
+            {'column': 'pixel', 'output_suffixes': ['_xl', '_yl', '_xr', '_yr', '_xa', '_ya']},
+            6,
+            pl.DataFrame({
+                'pixel_xl': [.1], 'pixel_yl': [.2],
+                'pixel_xr': [.3], 'pixel_yr': [.4],
+                'pixel_xa': [.5], 'pixel_ya': [.6],
+            }),
+            id='df_single_row_six_pixel_suffixes',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel'},
+            2,
+            pl.DataFrame({'pixel_x': [1.23], 'pixel_y': [4.56]}),
+            id='df_single_row_two_pixel_suffixes_default_values',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'abc': [1], 'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel'},
+            2,
+            pl.DataFrame({'abc': [1], 'pixel_x': [1.23], 'pixel_y': [4.56]}),
+            id='df_single_row_three_columns_two_pixel_suffixes_default_values',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.2, 3.4, 5.6, 7.8]]}),
+            {'column': 'pixel'},
+            4,
+            pl.DataFrame({
+                'pixel_xl': [1.2], 'pixel_yl': [3.4],
+                'pixel_xr': [5.6], 'pixel_yr': [7.8],
+            }),
+            id='df_single_row_four_pixel_suffixes_default_values',
+        ),
+
+        pytest.param(
+            pl.DataFrame({'pixel': [[0.1, 0.2, 0.3, 0.4, 0.5, 0.6]]}),
+            {'column': 'pixel'},
+            6,
+            pl.DataFrame({
+                'pixel_xl': [.1], 'pixel_yl': [.2],
+                'pixel_xr': [.3], 'pixel_yr': [.4],
+                'pixel_xa': [.5], 'pixel_ya': [.6],
+            }),
+            id='df_single_row_six_pixel_suffixes_default_values',
+        ),
+    ],
+)
+def test_gaze_dataframe_unnest_suffixes(init_data, unnest_kwargs, n_components, expected):
+    gaze = pm.GazeDataFrame(init_data)
+    gaze.n_components = n_components
+    gaze.unnest(**unnest_kwargs)
+    assert_frame_equal(gaze.frame, expected)
+
+
+@pytest.mark.parametrize(
+    ('init_data', 'unnest_kwargs', 'n_components', 'exception', 'exception_msg'),
+    [
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_y', '_z']},
+            2,
+            ValueError,
+            'Number of output columns / suffixes (3) must match number of components (2)',
+            id='df_single_row_two_pixel_components_three_output_suffixes',
+        ),
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_columns': ['x']},
+            2,
+            ValueError,
+            'Number of output columns / suffixes (1) must match number of components (2)',
+            id='df_single_row_two_pixel_components_one_output_column',
+        ),
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_x']},
+            2,
+            ValueError,
+            'Output columns / suffixes must be unique',
+            id='df_single_row_two_output_suffixes_non_unique',
+        ),
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_columns': ['x', 'x']},
+            2,
+            ValueError,
+            'Output columns / suffixes must be unique',
+            id='df_single_row_two_output_columns_non_unique',
+        ),
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_y'], 'output_columns': ['x', 'y']},
+            2,
+            ValueError,
+            'The arguments "output_columns" and "output_suffixes" are mutually exclusive.',
+            id='df_single_row_two_output_columns_and_suffixes',
+        ),
+        # invalid number of components
+        pytest.param(
+            pl.DataFrame({'pixel': [[1.23, 4.56]]}),
+            {'column': 'pixel', 'output_suffixes': ['_x', '_y']},
+            1,
+            AttributeError,
+            'n_components must be either 2, 4 or 6 but is 1',
+            id='df_single_row_two_pixel_component_invalid_number_of_components',
+        ),
+    ],
+
+)
+def test_gaze_dataframe_unnest_errors(
+        init_data, unnest_kwargs, n_components, exception, exception_msg,
+):
+    with pytest.raises(exception) as exc_info:
+        gaze = pm.GazeDataFrame(init_data)
+        gaze.n_components = n_components
+        gaze.unnest(**unnest_kwargs)
+
+    msg, = exc_info.value.args
+    assert msg == exception_msg
diff --git a/tests/plotting/tsplot_test.py b/tests/plotting/tsplot_test.py
index a4b1348..777fe85 100644
--- a/tests/plotting/tsplot_test.py
+++ b/tests/plotting/tsplot_test.py
@@ -81,7 +81,7 @@ def gaze_fixture():
 def test_tsplot_show(gaze, kwargs, monkeypatch):
     mock = Mock()
     monkeypatch.setattr(plt, 'show', mock)
-    gaze.unnest('pixel', ['x_pix', 'y_pix'])
+    gaze.unnest('pixel', output_columns=['x_pix', 'y_pix'])
     pm.plotting.tsplot(gaze=gaze, **kwargs)
     plt.close()
     mock.assert_called_once()