Repository: aeye-lab/pymovements
Pull request #424 (2023-05-29 14:02:22)

feat(gaze): Add pos2acc() method
## Description

Add position to acceleration transform function
This is done in the old style, as the polars transforms are still not fully integrated yet.

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change is or requires a documentation update

## How Has This Been Tested?

- [x] tests for exceptions
- [x] basic tests for 2nd derivative calculation

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`2afecda`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/2afecda611284d4429be8b1a9915e516988f4fd3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`2fb79be`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00%.

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


```diff
@@            Coverage Diff            @@
##              main      #424   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         1908      1946   +38     
  Branches       425       389   -36     
=========================================
+ Hits          1908      1946   +38     
```


| [Impacted Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?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/\_\_init\_\_.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvX19pbml0X18ucHk=) | `100.00% <ø> (ø)` | |
| [src/pymovements/dataset/dataset.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldC5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/gaze\_dataframe.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZ2F6ZV9kYXRhZnJhbWUucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/transforms.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvdHJhbnNmb3Jtcy5weQ==) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/424?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).


Patch:
diff --git a/src/pymovements/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index 18e94c2..20f2d34 100644
--- a/src/pymovements/dataset/dataset.py
+++ b/src/pymovements/dataset/dataset.py
@@ -266,6 +266,57 @@ class Dataset:
 
         return self
 
+    def pos2acc(
+            self,
+            window_length: int = 7,
+            degree: int = 2,
+            mode: str = 'interp',
+            cval: float = 0.0,
+            verbose: bool = True,
+    ) -> Dataset:
+        """Compute gaze accelerations in dva/s^2 from dva coordinates.
+
+        This method requires a properly initialized :py:attr:`~.Dataset.experiment` attribute.
+
+        After success, the gaze dataframe is extended by the resulting acceleration columns.
+
+        Parameters
+        ----------
+        window_length:
+            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.
+        verbose : bool
+            If True, show progress of computation.
+
+        Raises
+        ------
+        AttributeError
+            If `gaze` is None or there are no gaze dataframes present in the `gaze` attribute, or
+            if experiment is None.
+
+        Returns
+        -------
+        Dataset
+            Returns self, useful for method cascading.
+        """
+        self._check_gaze_dataframe()
+
+        disable_progressbar = not verbose
+        for gaze_df in tqdm(self.gaze, disable=disable_progressbar):
+            gaze_df.pos2acc(
+                window_length=window_length,
+                degree=degree,
+                mode=mode,
+                cval=cval,
+            )
+
+        return self
+
     def pos2vel(self, method: str = 'smooth', verbose: bool = True, **kwargs: Any) -> Dataset:
         """Compute gaze velocites in dva/s from dva coordinates.
 
diff --git a/src/pymovements/gaze/__init__.py b/src/pymovements/gaze/__init__.py
index 6826d7d..16f62dc 100644
--- a/src/pymovements/gaze/__init__.py
+++ b/src/pymovements/gaze/__init__.py
@@ -35,6 +35,7 @@
    :toctree:
 
    pymovements.gaze.transforms.pix2deg
+   pymovements.gaze.transforms.pos2acc
    pymovements.gaze.transforms.pos2vel
    pymovements.gaze.transforms.norm
    pymovements.gaze.transforms.split
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index e429b22..c825ee3 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -23,6 +23,7 @@ from __future__ import annotations
 import polars as pl
 
 from pymovements.gaze.experiment import Experiment
+from pymovements.gaze.transforms import pos2acc
 
 
 class GazeDataFrame:
@@ -50,6 +51,12 @@ class GazeDataFrame:
         'x_right_vel', 'y_right_vel',
     ]
 
+    _valid_acceleration_columns = [
+        'x_acc', 'y_acc',
+        'x_left_acc', 'y_left_acc',
+        'x_right_acc', 'y_right_acc',
+    ]
+
     def __init__(
             self,
             data: pl.DataFrame | None = None,
@@ -107,8 +114,69 @@ class GazeDataFrame:
             ],
         )
 
+    def pos2acc(
+            self,
+            window_length: int = 7,
+            degree: int = 2,
+            mode: str = 'interp',
+            cval: float = 0.0,
+    ) -> None:
+        """Compute gaze acceleration in dva/s^2 from dva position coordinates.
+
+        This method requires a properly initialized :py:attr:`~.GazeDataFrame.experiment` attribute.
+
+        After success, the gaze dataframe is extended by the resulting velocity columns.
+
+        Parameters
+        ----------
+        window_length:
+            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.
+
+        Raises
+        ------
+        AttributeError
+            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
+
+        position_columns = self.position_columns
+        if not position_columns:
+            raise AttributeError(
+                'No valid position columns found.'
+                f' Valid position columns are: {self._valid_position_columns}.'
+                f' Available columns are: {self.frame.columns}.',
+            )
+        acceleration_columns = self._position_to_acceleration_columns(position_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,
+        )
+
+        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)
+            ],
+        )
+
     def pos2vel(self, method: str = 'smooth', **kwargs: int | float | str) -> None:
-        """Compute gaze velocites in dva/s from dva position coordinates.
+        """Compute gaze velocity in dva/s from dva position coordinates.
 
         This method requires a properly initialized :py:attr:`~.GazeDataFrame.experiment` attribute.
 
@@ -161,6 +229,12 @@ class GazeDataFrame:
         """List of column names."""
         return self.frame.columns
 
+    @property
+    def acceleration_columns(self) -> list[str]:
+        """Acceleration columns (in degrees of visual angle per second^2) of dataframe."""
+        acceleration_columns = list(set(self._valid_acceleration_columns) & set(self.frame.columns))
+        return acceleration_columns
+
     @property
     def velocity_columns(self) -> list[str]:
         """Velocity columns (in degrees of visual angle per second) of dataframe."""
@@ -188,6 +262,15 @@ class GazeDataFrame:
             if column.endswith('_pix')
         ]
 
+    @staticmethod
+    def _position_to_acceleration_columns(columns: list[str]) -> list[str]:
+        """Get corresponding acceleration columns from dva position columns."""
+        return [
+            column.replace('_pos', '_acc')
+            for column in columns
+            if column.endswith('_pos')
+        ]
+
     @staticmethod
     def _position_to_velocity_columns(columns: list[str]) -> list[str]:
         """Get corresponding velocity columns from dva position columns."""
diff --git a/src/pymovements/gaze/transforms.py b/src/pymovements/gaze/transforms.py
index b8c97a4..bef72cc 100644
--- a/src/pymovements/gaze/transforms.py
+++ b/src/pymovements/gaze/transforms.py
@@ -161,6 +161,90 @@ def pix2deg(
     return np.arctan2(arr, distance_px) * 180 / np.pi
 
 
+def pos2acc(
+        arr: list[float] | list[list[float]] | np.ndarray,
+        sampling_rate: float,
+        window_length: int = 7,
+        degree: int = 2,
+        mode: str = 'interp',
+        cval: float = 0.0,
+) -> np.ndarray:
+    """Compute velocity time series from 2-dimensional position time series.
+
+    Parameters
+    ----------
+    arr : array_like
+        Continuous 2D position time series
+    sampling_rate:
+        Sampling rate of input time series.
+    degree:
+        The degree of the polynomial to use.
+    window_length:
+        The window size to use.
+    mode:
+        The padding mode to use.
+    cval:
+        A constant value for padding.
+
+    Returns
+    -------
+    np.ndarray
+        Velocity time series in input_unit / sec
+
+    Examples
+    --------
+    >>> arr = [(0., 0.), (1., 1.), (4., 4.), (9., 9.), (16., 16.), (25., 25.)]
+    >>> pos2acc(
+    ...    arr=arr,
+    ...    sampling_rate=1000,
+    ...    window_length=5,
+    ... )
+    array([[2000., 2000.],
+           [2000., 2000.],
+           [2000., 2000.],
+           [2000., 2000.],
+           [2000., 2000.],
+           [2000., 2000.]])
+    """
+    if sampling_rate <= 0:
+        raise ValueError('sampling_rate needs to be above zero')
+
+    # make sure that we're operating on a numpy array
+    arr = np.array(arr)
+
+    if arr.ndim not in [1, 2]:
+        raise ValueError(
+            'arr needs to have 1 or 2 dimensions (are: {arr.ndim = })',
+        )
+
+    acc = np.zeros(arr.shape)
+
+    # transform to velocities
+    if arr.ndim == 1:
+        acc = savgol_filter(
+            x=arr,
+            deriv=2,
+            window_length=window_length,
+            polyorder=degree,
+            mode=mode,
+            cval=cval,
+        )
+    else:  # we already checked for error cases
+
+        for channel_id in range(arr.shape[1]):
+            acc[:, channel_id] = savgol_filter(
+                x=arr[:, channel_id],
+                deriv=2,
+                window_length=window_length,
+                polyorder=degree,
+                mode=mode,
+                cval=cval,
+            )
+    acc = acc * sampling_rate
+
+    return acc
+
+
 def pos2vel(
         arr: list[float] | list[list[float]] | np.ndarray,
         sampling_rate: float = 1000,


Test patch:
diff --git a/tests/dataset/dataset_test.py b/tests/dataset/dataset_test.py
index 41880e5..3bc11d5 100644
--- a/tests/dataset/dataset_test.py
+++ b/tests/dataset/dataset_test.py
@@ -495,6 +495,26 @@ def test_pix2deg(dataset_configuration):
         assert result_gaze_df.schema == expected_schema
 
 
+def test_pos2acc(dataset_configuration):
+    dataset = pm.Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+
+    original_schema = dataset.gaze[0].schema
+
+    dataset.pos2acc()
+
+    acc_schema = {}
+    for column_name in original_schema.keys():
+        if column_name.endswith('_pix'):
+            acc_column_name = column_name.replace('_pix', '_acc')
+            acc_schema[acc_column_name] = original_schema[column_name]
+    expected_schema = {**original_schema, **acc_schema}
+
+    for result_gaze_df in dataset.gaze:
+        assert result_gaze_df.schema == expected_schema
+
+
 def test_pos2vel(dataset_configuration):
     dataset = pm.Dataset(**dataset_configuration['init_kwargs'])
     dataset.load()
diff --git a/tests/gaze/gaze_dataframe_test.py b/tests/gaze/gaze_dataframe_test.py
index 263de38..5d73ec6 100644
--- a/tests/gaze/gaze_dataframe_test.py
+++ b/tests/gaze/gaze_dataframe_test.py
@@ -263,39 +263,39 @@ def test_gaze_dataframe_pix2deg_exceptions(init_kwargs, exception, msg_substring
 
 
 @pytest.mark.parametrize(
-    ('init_df', 'expected_position_columns'),
+    ('init_df', 'expected_acceleration_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_pos', 'y_pos'],
-            id='no_eye_pos_columns',
+            ['x_acc', 'y_acc'],
+            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_pos', 'y_pos'],
-            id='no_eye_pos_columns_with_other_columns',
+            ['x_acc', 'y_acc'],
+            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_pos', 'y_right_pos'],
-            id='right_eye_pos_columns',
+            ['x_right_acc', 'y_right_acc'],
+            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_pos', 'y_left_pos'],
-            id='left_eye_pos_columns',
+            ['x_left_acc', 'y_left_acc'],
+            id='left_eye_pos_acc_columns',
         ),
         pytest.param(
             pl.DataFrame(
@@ -308,19 +308,113 @@ def test_gaze_dataframe_pix2deg_exceptions(init_kwargs, exception, msg_substring
                     'x_right_pix': pl.Float64, 'y_right_pix': pl.Float64,
                 },
             ),
-            ['x_left_pos', 'y_left_pos', 'x_right_pos', 'y_right_pos'],
-            id='both_eyes_pos_columns',
+            [
+                'x_left_acc', 'y_left_acc', 'x_right_acc', 'y_right_acc',
+            ],
+            id='both_eyes_pos_acc_columns',
+        ),
+    ],
+)
+def test_gaze_dataframe_pos2acc_has_correct_columns(
+        init_df, expected_acceleration_columns, experiment_fixture,
+):
+    gaze_df = GazeDataFrame(init_df, experiment=experiment_fixture)
+    gaze_df.pix2deg()
+    gaze_df.pos2acc()
+
+    assert set(gaze_df.acceleration_columns) == set(expected_acceleration_columns)
+
+
+@pytest.mark.parametrize(
+    ('init_kwargs', 'exception', 'msg_substrings'),
+    [
+        pytest.param(
+            {
+                'data': pl.DataFrame(schema={'x_pix': pl.Float64, 'y_pix': pl.Float64}),
+                'experiment': Experiment(1024, 768, 38, 30, 60, 'center', 1000),
+            },
+            AttributeError, ('position', 'columns', 'valid', 'x_pos', 'x_pix'),
+            id='no_dva_pos_columns',
+        ),
+        pytest.param(
+            {'data': pl.DataFrame(schema={'x_pos': pl.Float64, 'y_pos': pl.Float64})},
+            AttributeError, ('experiment', 'must'),
+            id='no_dva_pos_columns',
+        ),
+    ],
+)
+def test_gaze_dataframe_pos2acc_exceptions(init_kwargs, exception, msg_substrings):
+    gaze_df = GazeDataFrame(**init_kwargs)
+
+    with pytest.raises(exception) as excinfo:
+        gaze_df.pos2acc()
+
+    msg, = excinfo.value.args
+    for msg_substring in msg_substrings:
+        assert msg_substring.lower() in msg.lower()
+
+
+@pytest.mark.parametrize(
+    ('init_df', 'expected_velocity_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_vel', 'y_vel'],
+            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_vel', 'y_vel'],
+            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_vel', 'y_right_vel'],
+            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_vel', 'y_left_vel'],
+            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_vel', 'y_left_vel', 'x_right_vel', 'y_right_vel',
+            ],
+            id='both_eyes_pos_vel_columns',
         ),
     ],
 )
 def test_gaze_dataframe_pos2vel_has_correct_columns(
-        init_df, expected_position_columns, experiment_fixture,
+        init_df, expected_velocity_columns, experiment_fixture,
 ):
     gaze_df = GazeDataFrame(init_df, experiment=experiment_fixture)
     gaze_df.pix2deg()
     gaze_df.pos2vel()
 
-    assert set(gaze_df.position_columns) == set(expected_position_columns)
+    assert set(gaze_df.velocity_columns) == set(expected_velocity_columns)
 
 
 @pytest.mark.parametrize(
diff --git a/tests/gaze/transforms_test.py b/tests/gaze/transforms_test.py
index 9323cf7..6281a1a 100644
--- a/tests/gaze/transforms_test.py
+++ b/tests/gaze/transforms_test.py
@@ -25,6 +25,7 @@ import pytest
 
 from pymovements.gaze.transforms import norm
 from pymovements.gaze.transforms import pix2deg
+from pymovements.gaze.transforms import pos2acc
 from pymovements.gaze.transforms import pos2vel
 from pymovements.gaze.transforms import split
 
@@ -418,6 +419,72 @@ def test_pix2deg_returns(kwargs, expected_value):
     assert (actual_value == expected_value).all()
 
 
+@pytest.mark.parametrize(
+    ('kwargs', 'expected_error'),
+    [
+        pytest.param(
+            {
+                'arr': [[0] * 10],
+                'sampling_rate': 0,
+            },
+            ValueError,
+            id='sampling_rate_zero_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'arr': [[0] * 10],
+                'sampling_rate': -1,
+            },
+            ValueError,
+            id='sampling_rate_less_zero_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'arr': np.ones((3, 3, 3)),
+                'sampling_rate': 1,
+            },
+            ValueError,
+            id='wrong_dimensions_input_arr_raises_value_error',
+        ),
+    ],
+)
+def test_pos2acc_raises_error(kwargs, expected_error):
+    with pytest.raises(expected_error):
+        pos2acc(**kwargs)
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'padding', 'expected_value'),
+    [
+        pytest.param(
+            {
+                'arr': np.repeat(0, n_coords),
+                'sampling_rate': 1,
+            },
+            (0, n_coords),
+            np.zeros(n_coords),
+            id='constant_input_returns_zero_velocity',
+        ),
+        pytest.param(
+            {
+                'arr': np.square(np.linspace(0, n_coords - 1, n_coords)),
+                'sampling_rate': 1,
+            },
+            (1, -1),
+            2 * np.ones(n_coords),
+            id='linear_input_returns_constant_velocity',
+        ),
+    ],
+)
+def test_pos2acc_returns(kwargs, padding, expected_value):
+    actual_value = pos2acc(**kwargs)
+
+    assert np.allclose(
+        actual_value[padding[0]:padding[1]],
+        expected_value[padding[0]:padding[1]],
+    )
+
+
 @pytest.mark.parametrize(
     ('kwargs', 'expected_error'),
     [