Repository: aeye-lab/pymovements
Pull request #174 (2023-02-20 18:16:42)

Datasets: Add option to select eye for event detection
This lets the user choose which eye they want to use for event detection.

codecov-commenter: # [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/174?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Base: **59.87**% // Head: **59.41**% // Decreases project coverage by **`-0.46%`** :warning:
> Coverage data is based on head [(`9882a34`)](https://codecov.io/gh/aeye-lab/pymovements/pull/174?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) compared to base [(`2b2f909`)](https://codecov.io/gh/aeye-lab/pymovements/commit/2b2f90975d81daf15cb123aedb65504f38f5c123?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).
> Patch coverage: 0.00% of modified lines in pull request are covered.

> :exclamation: Current head 9882a34 differs from pull request most recent head 7304a97. Consider uploading reports for the commit 7304a97 to get more accurate results

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


```diff
@@            Coverage Diff             @@
##             main     #174      +/-   ##
==========================================
- Coverage   59.87%   59.41%   -0.46%     
==========================================
  Files          21       21              
  Lines         775      781       +6     
  Branches      178      181       +3     
==========================================
  Hits          464      464              
- Misses        299      305       +6     
  Partials       12       12              
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/174?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/datasets/dataset.py](https://codecov.io/gh/aeye-lab/pymovements/pull/174?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2RhdGFzZXQucHk=) | `23.11% <0.00%> (-0.72%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/174?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/datasets/dataset.py b/src/pymovements/datasets/dataset.py
index 5a265c7..b5d675a 100644
--- a/src/pymovements/datasets/dataset.py
+++ b/src/pymovements/datasets/dataset.py
@@ -393,6 +393,7 @@ class Dataset:
     def detect_events(
             self,
             method: EventDetectionCallable,
+            eye: str = 'auto',
             verbose: bool = True,
             **kwargs,
     ) -> None:
@@ -402,6 +403,10 @@ class Dataset:
         ----------
         method : EventDetectionCallable
             The event detection method to be applied.
+        eye : str
+            Select which eye to choose. Valid options are ``auto``, ``left`` or ``right``. If
+            ``auto`` is passed, ``left`` will only be chosen if the right eye is not available in
+            the gaze data frame.
         verbose : bool
             If ``True``, show progress bar.
         **kwargs :
@@ -415,8 +420,20 @@ class Dataset:
         if len(self.gaze) == 0:
             raise AttributeError('no files present in gaze attribute')
 
-        position_columns = ['x_left_dva', 'y_left_dva']
-        velocity_columns = ['x_left_vel', 'y_left_vel']
+        # Automatically infer eye to use for event detection.
+        if eye == 'auto':
+            if 'x_right_dva' in self.gaze[0].columns:
+                eye = 'right'
+            elif 'x_left_dva' in self.gaze[0].columns:
+                eye = 'left'
+            else:
+                raise AttributeError(
+                    'Either right or left eye columns must be present in gaze data frame.'
+                    f' Available columns are: {self.gaze[0].columns}',
+                )
+
+        position_columns = [f'x_{eye}_dva', f'y_{eye}_dva']
+        velocity_columns = [f'x_{eye}_vel', f'y_{eye}_vel']
 
         disable_progressbar = not verbose
 
diff --git a/src/pymovements/events/idt.py b/src/pymovements/events/idt.py
index d49b0b7..d59c3c1 100644
--- a/src/pymovements/events/idt.py
+++ b/src/pymovements/events/idt.py
@@ -111,16 +111,16 @@ def idt(
                 win_end += 1
 
                 # break if we reach end of input data
-                if win_end == len(positions) - 1:
+                if win_end == len(positions):
                     break
 
             # Note a fixation at the centroid of the window points
-            centroid = np.mean(positions[win_start:win_end], axis=0)
+            centroid = np.mean(positions[win_start:win_end - 1], axis=0)
 
             fixations.append({
                 'type': 'fixation',
                 'onset': win_start,
-                'offset': win_end,
+                'offset': win_end - 1,
                 'position': centroid.tolist(),
             })
 
diff --git a/src/pymovements/transforms.py b/src/pymovements/transforms.py
index 551e38d..f9c5e49 100644
--- a/src/pymovements/transforms.py
+++ b/src/pymovements/transforms.py
@@ -312,10 +312,10 @@ def norm(arr: np.ndarray, axis: int | None = None) -> np.ndarray | Any:
     return np.linalg.norm(arr, axis=axis)
 
 
-def cut_into_subsequences(
+def split(
     arr: np.ndarray, window_size: int, keep_padded: bool = True,
 ) -> np.ndarray:
-    """Cut sequence into subsequences of equal length.
+    """Split sequence into subsequences of equal length.
 
     Parameters
     ----------
@@ -330,7 +330,7 @@ def cut_into_subsequences(
     Returns
     -------
     np.ndarray
-        Output sequence with new window length of shape (N', L', C)
+        Array of split sequences with new window length of shape (N', L', C)
 
     Examples
     --------
@@ -340,19 +340,19 @@ def cut_into_subsequences(
     >>> arr.shape
     (1, 13, 2)
 
-    We now cut the original array into three subsequences of length `5`.
+    We now split the original array into three subsequences of length `5`.
 
-    >>> arr_cut = cut_into_subsequences(
+    >>> arr_split = split(
     ...    arr=arr,
     ...    window_size=5,
     ...    keep_padded=True,
     ... )
-    >>> arr_cut.shape
+    >>> arr_split.shape
     (3, 5, 2)
 
     The last subsequence is padded with `nan` to have a length of `5`.
 
-    >>> arr_cut[-1]
+    >>> arr_split[-1]
     array([[ 1.,  1.],
            [ 1.,  1.],
            [ 1.,  1.],
@@ -362,12 +362,12 @@ def cut_into_subsequences(
     We can also drop any remaining sequences that would need padding by passing
     ``keep_padded=False``.
 
-    >>> arr_cut = cut_into_subsequences(
+    >>> arr_split = split(
     ...    arr=arr,
     ...    window_size=5,
     ...    keep_padded=False,
     ... )
-    >>> arr_cut.shape
+    >>> arr_split.shape
     (2, 5, 2)
 
     """
@@ -378,32 +378,26 @@ def cut_into_subsequences(
     else:
         n_rows = arr.shape[0] * n
 
-    arr_cut = np.nan * np.ones((n_rows, window_size, arr.shape[2]))
+    arr_split = np.nan * np.ones((n_rows, window_size, arr.shape[2]))
 
     idx = 0
-    for t in range(0, arr.shape[0]):
-        for i in range(0, n):
-            # get part of the sequence with window_size
-            arr_tmp = np.expand_dims(arr[t, i * window_size: (i + 1) * window_size, :], axis=0)
+    for arr_instance in arr:
+        # Create an array of indicies where sequence will be split.
+        split_indices = np.arange(start=window_size, stop=(n + 1) * window_size, step=window_size)
 
-            # fill row with respective part of the sequence
-            arr_cut[idx, :, :] = arr_tmp
+        # The length of the last element in list will be equal to rest.
+        arr_split_list = np.split(arr_instance, split_indices)
 
-            idx = idx + 1
+        # Put the first n elements of split list into output array.
+        arr_split[idx:idx + n] = arr_split_list[:-1]
+        idx += n
 
         if rest > 0 and keep_padded:
-            # get end point of last part of the sequence
-            start_idx_last_piece = window_size * n
+            # Insert last split, remaining padded samples are already np.nan.
+            arr_split[idx, :rest] = arr_split_list[-1]
+            idx += 1
 
-            # get last part of sequence which is shorter than window_size
-            arr_incomplete = arr[t, start_idx_last_piece:, :]
-
-            # insert last part, remaining samples are already np.nan
-            arr_cut[idx, :rest, :] = arr_incomplete
-
-            idx = idx + 1
-
-    return arr_cut
+    return arr_split
 
 
 def downsample(


Test patch:
diff --git a/tests/events/test_idt.py b/tests/events/test_idt.py
index d6cc12a..7d7d372 100644
--- a/tests/events/test_idt.py
+++ b/tests/events/test_idt.py
@@ -142,7 +142,7 @@ def test_idt_raises_error(kwargs, expected_error):
                     'type': ['fixation'],
                     'onset': [0],
                     'offset': [99],
-                    'position': [(0.0, 0.0)],
+                    'position': [[0.0, 0.0]],
                 },
             ),
             id='constant_position_single_fixation',
@@ -162,8 +162,8 @@ def test_idt_raises_error(kwargs, expected_error):
                 {
                     'type': 'fixation',
                     'onset': [0, 50],
-                    'offset': [50, 99],  # should be [49, 99]
-                    'position': [(0.18, 0.18), (1.0, 1.0)],  # should be: [(0.0, 0.0), (1.0, 1.0)]
+                    'offset': [49, 99],
+                    'position': [[0.0, 0.0], [1.0, 1.0]],
                 },
             ),
             id='three_steps_two_fixations',
@@ -171,6 +171,7 @@ def test_idt_raises_error(kwargs, expected_error):
     ],
 )
 def test_idt_detects_fixations(kwargs, expected):
+    """Test if idt detects fixations."""
     velocities = pos2vel(kwargs['positions'], sampling_rate=10, method='preceding')
     events = idt(velocities=velocities, **kwargs)
 
diff --git a/tests/test_transforms.py b/tests/test_transforms.py
index 9286e2a..abe0f17 100644
--- a/tests/test_transforms.py
+++ b/tests/test_transforms.py
@@ -23,9 +23,9 @@ Test all functions in pymovements.transforms.
 import numpy as np
 import pytest
 
-from pymovements.transforms import cut_into_subsequences
 from pymovements.transforms import pix2deg
 from pymovements.transforms import pos2vel
+from pymovements.transforms import split
 
 
 n_coords = 100
@@ -511,6 +511,11 @@ def test_pos2vel_stepped_input_returns(params, expected_value):
 @pytest.mark.parametrize(
     'params, expected',
     [
+        pytest.param(
+            {'arr': np.ones((1, 10, 2)), 'window_size': 2, 'keep_padded': False},
+            {'value': np.ones((5, 2, 2))},
+            id='length_double_window_size2_keep_padded_returns_two_instances',
+        ),
         pytest.param(
             {'arr': np.ones((1, 10, 2)), 'window_size': 5, 'keep_padded': False},
             {'value': np.ones((2, 5, 2))},
@@ -536,15 +541,32 @@ def test_pos2vel_stepped_input_returns(params, expected_value):
             },
             id='length_double_window_size+1_not_keep_padded_returns_three_instances',
         ),
+        pytest.param(
+            {'arr': np.ones((2, 10, 2)), 'window_size': 5, 'keep_padded': False},
+            {'value': np.ones((4, 5, 2))},
+            id='two_instances_length_double_window_size_keep_padded_returns_two_instances',
+        ),
+        pytest.param(
+            {'arr': np.ones((2, 11, 2)), 'window_size': 5, 'keep_padded': True},
+            {
+                'value': np.concatenate([
+                    np.ones((2, 5, 2)),
+                    np.expand_dims(np.concatenate([np.ones((1, 2)), np.ones((4, 2)) * np.nan]), 0),
+                    np.ones((2, 5, 2)),
+                    np.expand_dims(np.concatenate([np.ones((1, 2)), np.ones((4, 2)) * np.nan]), 0),
+                ]),
+            },
+            id='two_instances_length_double_window_size+1_not_keep_padded_returns_six_instances',
+        ),
     ],
 )
 def test_cut_into_subsequences(params, expected):
     if 'exception' in expected:
         with pytest.raises(expected['exception']):
-            cut_into_subsequences(**params)
+            split(**params)
         return
 
-    arr = cut_into_subsequences(**params)
+    arr = split(**params)
 
     assert np.array_equal(arr, expected['value'], equal_nan=True), (
         f"arr = {arr}, expected = {expected['value']}"