Repository: aeye-lab/pymovements
Pull request #261 (2023-03-07 09:43:24)

Events: Use timesteps in event detection methods
## Description

This adds support for actual time units to the event detection functions.

## Implemented changes

- [x] Add a `timesteps` argument to the `EventDetectionCallable(Protocol)` and to `idt()`, `ivt()` and `microsaccades()`
- [x] Result `EventDataFrame` now has `onset` and `offset` values specified according to the `timesteps` values

## 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] Added a single test case to each event detection functions which has an additional `timesteps` argument and returns an `EventDataFrame` with `onset` and `offset` columns according to `timesteps` values

## 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-commenter: # [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/261?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.56`**% and project coverage change: **`-0.41`** :warning:
> Comparison is base [(`2609840`)](https://codecov.io/gh/aeye-lab/pymovements/commit/2609840ca6ac60c1b0443583c77d4f06a198b9a1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 97.89% compared to head [(`197b4f5`)](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 97.48%.

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


```diff
@@            Coverage Diff             @@
##             main     #261      +/-   ##
==========================================
- Coverage   97.89%   97.48%   -0.41%     
==========================================
  Files          24       25       +1     
  Lines         997     1035      +38     
  Branches      233      240       +7     
==========================================
+ Hits          976     1009      +33     
- Misses         19       22       +3     
- Partials        2        4       +2     
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/base.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2Jhc2UucHk=) | `100.00% <ø> (ø)` | |
| [src/pymovements/utils/checks.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL2NoZWNrcy5weQ==) | `100.00% <ø> (ø)` | |
| [src/pymovements/events/idt.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9pZHQucHk=) | `92.72% <80.95%> (-7.28%)` | :arrow_down: |
| [src/pymovements/events/events.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9ldmVudHMucHk=) | `98.24% <95.45%> (-1.76%)` | :arrow_down: |
| [src/pymovements/datasets/dataset.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2RhdGFzZXQucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/engbert.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9lbmdiZXJ0LnB5) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/event\_properties.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9ldmVudF9wcm9wZXJ0aWVzLnB5) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/ivt.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9pdnQucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/exceptions.py](https://codecov.io/gh/aeye-lab/pymovements/pull/261?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V4Y2VwdGlvbnMucHk=) | `100.00% <100.00%> (ø)` | |

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/261?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).

dkrako: this should now reach 100% coverage

Patch:
diff --git a/src/pymovements/datasets/dataset.py b/src/pymovements/datasets/dataset.py
index 50223e5..c1ef957 100644
--- a/src/pymovements/datasets/dataset.py
+++ b/src/pymovements/datasets/dataset.py
@@ -505,8 +505,12 @@ class Dataset:
 
             positions = gaze_df.select(position_columns).to_numpy()
             velocities = gaze_df.select(velocity_columns).to_numpy()
+            timesteps = gaze_df.select('time').to_numpy()
+
+            event_df = method(
+                positions=positions, velocities=velocities, timesteps=timesteps, **kwargs,
+            )
 
-            event_df = method(positions=positions, velocities=velocities, **kwargs)
             event_df.frame = self._add_fileinfo(event_df.frame, fileinfo)
 
             event_dfs.append(event_df)
diff --git a/src/pymovements/events/engbert.py b/src/pymovements/events/engbert.py
index 13e6bcc..2acee91 100644
--- a/src/pymovements/events/engbert.py
+++ b/src/pymovements/events/engbert.py
@@ -28,16 +28,17 @@ import numpy as np
 
 from pymovements.events.events import EventDataFrame
 from pymovements.transforms import consecutive
-from pymovements.utils.checks import check_shapes_positions_velocities
+from pymovements.utils import checks
 from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 def microsaccades(
         positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
         velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+        timesteps: list[int] | np.ndarray | None = None,
+        minimum_duration: int = 6,
         threshold: np.ndarray | tuple[float] | str = 'engbert2015',
         threshold_factor: float = 6,
-        minimum_duration: int = 6,
         minimum_threshold: float = 1e-10,
         include_nan: bool = False,
 ) -> EventDataFrame:
@@ -46,9 +47,9 @@ def microsaccades(
     This algorithm has a noise-adaptive velocity threshold parameter, which can also be set
     explicitly.
 
-    The implemetation is based on the description from Engbert & Kliegl :cite:p:`EngbertKliegl2003`
-    and is adopted from the Microsaccade Toolbox 0.9 originally implemented in R
-    :cite:p:`Engbert2015`.
+    The implemetation and its default parameter values are based on the description from
+    Engbert & Kliegl :cite:p:`EngbertKliegl2003` and is adopted from the Microsaccade Toolbox 0.9
+    originally implemented in R :cite:p:`Engbert2015`.
 
     Parameters
     ----------
@@ -56,14 +57,18 @@ def microsaccades(
         x and y positions of N samples in chronological order
     velocities : np.ndarray, shape (N, 2)
         x and y velocities of N samples in chronological order
+    timesteps: array-like, shape (N, )
+        Corresponding continuous 1D timestep time series. If None, sample based timesteps are
+        assumed.
+    minimum_duration: int
+        Minimum saccade duration. The duration is specified in the units used in ``timesteps``.
+         If ``timesteps`` is None, then ``minimum_duration`` is specified in numbers of samples.
     threshold : np.ndarray, tuple[float, float] or str
         If tuple of floats then use this as explicit elliptic threshold. If str, then use
         a data-driven velocity threshold method. See :func:`~events.engbert.compute_threshold` for
         a reference of valid methods. Default: `engbert2015`
     threshold_factor : float
         factor for relative velocity threshold computation. Default: 6
-    minimum_duration : int
-        minimal saccade duration in samples. Default: 6
     minimum_threshold : float
         minimal threshold value. Raises ValueError if calculated threshold is too low.
         Default: 1e-10
@@ -84,7 +89,12 @@ def microsaccades(
     positions = np.array(positions)
     velocities = np.array(velocities)
 
-    check_shapes_positions_velocities(positions=positions, velocities=velocities)
+    checks.check_shapes_positions_velocities(positions=positions, velocities=velocities)
+
+    if timesteps is None:
+        timesteps = np.arange(len(velocities), dtype=np.int64)
+    timesteps = np.array(timesteps)
+    checks.check_is_length_matching(velocities=velocities, timesteps=timesteps)
 
     if isinstance(threshold, str):
         threshold = compute_threshold(velocities, method=threshold)
@@ -115,20 +125,21 @@ def microsaccades(
     # Get all saccade candidates by grouping all consecutive indices.
     candidates = consecutive(arr=candidate_indices)
 
-    # Filter np.nan in candidates (delete starting/ending np.nans)
+    # Remove leading and trailing nan values from candidates.
     if include_nan:
-        candidates = filter_candidates_remove_nans(
-            candidates,
-            velocities,
-        )
+        candidates = filter_candidates_remove_nans(candidates=candidates, values=velocities)
 
     # Filter all candidates by minimum duration.
-    candidates = [candidate for candidate in candidates if len(candidate) >= minimum_duration]
+    candidates = [
+        candidate for candidate in candidates
+        if len(candidate) > 0
+        and timesteps[candidate[-1]] - timesteps[candidate[0]] >= minimum_duration
+    ]
 
     # Onset of each event candidate is first index in candidate indices.
-    onsets = [candidate_indices[0] for candidate_indices in candidates]
+    onsets = timesteps[[candidate_indices[0] for candidate_indices in candidates]].flatten()
     # Offset of each event candidate is last event in candidate indices.
-    offsets = [candidate_indices[-1] for candidate_indices in candidates]
+    offsets = timesteps[[candidate_indices[-1] for candidate_indices in candidates]].flatten()
 
     # Create event dataframe from onsets and offsets.
     event_df = EventDataFrame(name='saccade', onsets=onsets, offsets=offsets)
diff --git a/src/pymovements/events/events.py b/src/pymovements/events/events.py
index d52325e..67d7e0d 100644
--- a/src/pymovements/events/events.py
+++ b/src/pymovements/events/events.py
@@ -46,8 +46,8 @@ class EventDataFrame:
             self,
             data: pl.DataFrame | None = None,
             name: str | list[str] | None = None,
-            onsets: list[int] | None = None,
-            offsets: list[int] | None = None,
+            onsets: list[int] | np.ndarray | None = None,
+            offsets: list[int] | np.ndarray | None = None,
     ):
         """Initialize an :py:class:`pymovements.events.event_dataframe.EventDataFrame`.
 
@@ -181,6 +181,8 @@ class EventDetectionCallable(Protocol):
             self,
             positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
             velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+            timesteps: list[int] | np.ndarray | None = None,
+            minimum_duration: int = 0,
             **kwargs,
     ) -> EventDataFrame:
         """Minimal interface to be implemented by all event detection methods.
@@ -191,6 +193,12 @@ class EventDetectionCallable(Protocol):
             Continuous 2D position time series
         velocities: array-like, shape (N, 2)
             Corresponding continuous 2D velocity time series.
+        timesteps: array-like, shape (N, )
+            Corresponding continuous 1D timestep time series. If None, sample based timesteps are
+            assumed.
+        minimum_duration: int
+            Minimum event duration. The duration is specified in the units used in ``timesteps``.
+            If ``timesteps`` is None, then ``minimum_duration`` is specified in numbers of samples.
         **kwargs:
             Additional keyword arguments for the specific event detection method.
 
diff --git a/src/pymovements/events/idt.py b/src/pymovements/events/idt.py
index faeef3e..4a98822 100644
--- a/src/pymovements/events/idt.py
+++ b/src/pymovements/events/idt.py
@@ -25,7 +25,7 @@ from __future__ import annotations
 import numpy as np
 
 from pymovements.events.events import EventDataFrame
-from pymovements.utils.checks import check_shapes_positions_velocities
+from pymovements.utils import checks
 from pymovements.utils.filters import events_split_nans
 from pymovements.utils.filters import filter_candidates_remove_nans
 
@@ -53,8 +53,9 @@ def dispersion(positions: list[list[float]] | np.ndarray) -> float:
 def idt(
         positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
         velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
-        dispersion_threshold: float,
-        minimum_duration: int,
+        timesteps: list[int] | np.ndarray | None = None,
+        minimum_duration: int = 100,
+        dispersion_threshold: float = 1.0,
         include_nan: bool = False,
 ) -> EventDataFrame:
     """
@@ -66,7 +67,7 @@ def idt(
     If the dispersion is below the threshold, the window represents a fixation,
     and the window is expanded until the dispersion is above threshold.
 
-    The implementation is based on the description and pseudocode
+    The implementation and its default parameter values are based on the description and pseudocode
     from Salvucci and Goldberg :cite:p:`SalvucciGoldberg2000`.
 
     Parameters
@@ -75,10 +76,14 @@ def idt(
         Continuous 2D position time series
     velocities: array-like, shape (N, 2)
         Corresponding continuous 2D velocity time series.
+    timesteps: array-like, shape (N, )
+        Corresponding continuous 1D timestep time series. If None, sample based timesteps are
+        assumed.
+    minimum_duration: int
+        Minimum fixation duration. The duration is specified in the units used in ``timesteps``.
+         If ``timesteps`` is None, then ``minimum_duration`` is specified in numbers of samples.
     dispersion_threshold: float
         Threshold for dispersion for a group of consecutive samples to be identified as fixation
-    minimum_duration: int
-        Minimum fixation duration in number of samples
     include_nan: bool
         Indicator, whether we want to split events on missing/corrupt value (np.nan)
 
@@ -96,8 +101,13 @@ def idt(
     """
     positions = np.array(positions)
     velocities = np.array(velocities)
+    checks.check_shapes_positions_velocities(positions=positions, velocities=velocities)
+
+    if timesteps is None:
+        timesteps = np.arange(len(velocities), dtype=np.int64)
+    timesteps = np.array(timesteps)
+    checks.check_is_length_matching(velocities=velocities, timesteps=timesteps)
 
-    check_shapes_positions_velocities(positions=positions, velocities=velocities)
     if dispersion_threshold <= 0:
         raise ValueError('dispersion threshold must be greater than 0')
     if minimum_duration <= 0:
@@ -106,15 +116,28 @@ def idt(
     onsets = []
     offsets = []
 
+    # Infer minimum duration in number of samples.
+    # This implementation is currently very restrictive.
+    # It requires that the interval between timesteps is constant.
+    # It requires that the minimum duration is divisible by the constant interval between timesteps.
+    timesteps_diff = np.diff(timesteps)
+    if not np.all(timesteps_diff == timesteps_diff[0]):
+        raise ValueError('interval between timesteps must be constant')
+    if not minimum_duration % timesteps_diff[0] == 0:
+        raise ValueError(
+            'minimum_duration must be divisible by the constant interval between timesteps',
+        )
+    minimum_sample_duration = minimum_duration // timesteps_diff[0]
+
     # Initialize window over first points to cover the duration threshold
     win_start = 0
-    win_end = minimum_duration
+    win_end = minimum_sample_duration
 
     while win_end < len(positions):
 
         # Initialize window over first points to cover the duration threshold.
         # This automatically extends the window to the specified minimum event duration.
-        win_end = max(win_start + minimum_duration, win_end)
+        win_end = max(win_start + minimum_sample_duration, win_end)
 
         if dispersion(positions[win_start:win_end]) <= dispersion_threshold:
             # Add additional points to the window until dispersion > threshold.
@@ -141,17 +164,18 @@ def idt(
 
                 # Filter all candidates by minimum duration.
                 tmp_candidates = [
-                    candidate for candidate in tmp_candidates if len(candidate) >= minimum_duration
+                    candidate for candidate in tmp_candidates
+                    if len(candidate) >= minimum_sample_duration
                 ]
                 for candidate in tmp_candidates:
-                    onsets.append(candidate[0])
-                    offsets.append(candidate[-1])
+                    onsets.append(timesteps[candidate[0]])
+                    offsets.append(timesteps[candidate[-1]])
 
             else:
                 # Note a fixation at the centroid of the window points.
 
-                onsets.append(win_start)
-                offsets.append(win_end - 1)
+                onsets.append(timesteps[win_start])
+                offsets.append(timesteps[win_end - 1])
 
             # Remove window points from points.
             # Initialize new window excluding the previous window
@@ -161,5 +185,9 @@ def idt(
             # Move window start one step further without modifying window end.
             win_start += 1
 
-    event_df = EventDataFrame(name='fixation', onsets=onsets, offsets=offsets)
+    # Create proper flat numpy arrays.
+    onsets_arr = np.array(onsets).flatten()
+    offsets_arr = np.array(offsets).flatten()
+
+    event_df = EventDataFrame(name='fixation', onsets=onsets_arr, offsets=offsets_arr)
     return event_df
diff --git a/src/pymovements/events/ivt.py b/src/pymovements/events/ivt.py
index 1e42207..69106ab 100644
--- a/src/pymovements/events/ivt.py
+++ b/src/pymovements/events/ivt.py
@@ -27,15 +27,16 @@ import numpy as np
 from pymovements.events.events import EventDataFrame
 from pymovements.transforms import consecutive
 from pymovements.transforms import norm
-from pymovements.utils.checks import check_shapes_positions_velocities
+from pymovements.utils import checks
 from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 def ivt(
         positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
         velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
-        velocity_threshold: float,
-        minimum_duration: int,
+        timesteps: list[int] | np.ndarray | None = None,
+        minimum_duration: int = 100,
+        velocity_threshold: float = 20.0,
         include_nan: bool = False,
 ) -> EventDataFrame:
     """
@@ -45,7 +46,7 @@ def ivt(
     the given velocity threshold. Consecutive fixation points are merged into
     one fixation.
 
-    The implementation is based on the description and pseudocode
+    The implementation and its default parameter values are based on the description and pseudocode
     from Salvucci and Goldberg :cite:p:`SalvucciGoldberg2000`.
 
     Parameters
@@ -54,11 +55,15 @@ def ivt(
         Continuous 2D position time series.
     velocities: array-like, shape (N, 2)
         Corresponding continuous 2D velocity time series.
+    timesteps: array-like, shape (N, )
+        Corresponding continuous 1D timestep time series. If None, sample based timesteps are
+        assumed.
+    minimum_duration: int
+        Minimum fixation duration. The duration is specified in the units used in ``timesteps``.
+         If ``timesteps`` is None, then ``minimum_duration`` is specified in numbers of samples.
     velocity_threshold: float
         Threshold for a point to be classified as a fixation. If the
         velocity is below the threshold, the point is classified as a fixation.
-    minimum_duration: int
-        Minimum fixation duration in number of samples
     include_nan: bool
         Indicator, whether we want to split events on missing/corrupt value (np.nan)
 
@@ -79,12 +84,17 @@ def ivt(
     positions = np.array(positions)
     velocities = np.array(velocities)
 
-    check_shapes_positions_velocities(positions=positions, velocities=velocities)
+    checks.check_shapes_positions_velocities(positions=positions, velocities=velocities)
     if velocity_threshold is None:
         raise ValueError('velocity threshold must not be None')
     if velocity_threshold <= 0:
         raise ValueError('velocity threshold must be greater than 0')
 
+    if timesteps is None:
+        timesteps = np.arange(len(velocities), dtype=np.int64)
+    timesteps = np.array(timesteps)
+    checks.check_is_length_matching(velocities=velocities, timesteps=timesteps)
+
     # Get all indices with norm-velocities below threshold.
     velocity_norm = norm(velocities, axis=1)
     candidate_mask = velocity_norm < velocity_threshold
@@ -94,25 +104,25 @@ def ivt(
         candidate_mask = np.logical_or(candidate_mask, np.isnan(velocities).any(axis=1))
 
     # Get indices of true values in candidate mask.
-    candidate_ind = np.where(candidate_mask)[0]
+    candidate_indices = np.where(candidate_mask)[0]
 
     # Get all fixation candidates by grouping all consecutive indices.
-    candidates = consecutive(arr=candidate_ind)
+    candidates = consecutive(arr=candidate_indices)
 
-    # Filter np.nan in candidates (delete starting/ending np.nans)
+    # Remove leading and trailing nan values from candidates.
     if include_nan:
-        candidates = filter_candidates_remove_nans(
-            candidates,
-            velocities,
-        )
+        candidates = filter_candidates_remove_nans(candidates=candidates, values=velocities)
 
     # Filter all candidates by minimum duration.
-    candidates = [candidate for candidate in candidates if len(candidate) >= minimum_duration]
+    candidates = [
+        candidate for candidate in candidates
+        if timesteps[candidate[-1]] - timesteps[candidate[0]] >= minimum_duration
+    ]
 
     # Onset of each event candidate is first index in candidate indices.
-    onsets = [candidate_indices[0] for candidate_indices in candidates]
+    onsets = timesteps[[candidate_indices[0] for candidate_indices in candidates]].flatten()
     # Offset of each event candidate is last event in candidate indices.
-    offsets = [candidate_indices[-1] for candidate_indices in candidates]
+    offsets = timesteps[[candidate_indices[-1] for candidate_indices in candidates]].flatten()
 
     # Create event dataframe from onsets and offsets.
     event_df = EventDataFrame(name='fixation', onsets=onsets, offsets=offsets)


Test patch:
diff --git a/tests/events/engbert_test.py b/tests/events/engbert_test.py
index 768d891..67accd7 100644
--- a/tests/events/engbert_test.py
+++ b/tests/events/engbert_test.py
@@ -170,6 +170,25 @@ def test_microsaccades_raises_error(kwargs, expected):
             EventDataFrame(),
             id='string_based_threshold',
         ),
+        pytest.param(
+            {
+                'positions': step_function(length=100, steps=[0], values=[(0, 0)]),
+                'velocities': step_function(
+                    length=100,
+                    steps=[40, 50],
+                    values=[(9, 9), (0, 0)],
+                    start_value=(0, 0),
+                ),
+                'timesteps': np.arange(1000, 1100, dtype=int),
+                'threshold': 1e-5,
+            },
+            EventDataFrame(
+                name='saccade',
+                onsets=[1040],
+                offsets=[1049],
+            ),
+            id='two_steps_one_saccade_timesteps',
+        ),
     ],
 )
 def test_microsaccades_detects_saccades(kwargs, expected):
diff --git a/tests/events/event_dataframe_test.py b/tests/events/event_dataframe_test.py
index a2a6f6c..6e7322d 100644
--- a/tests/events/event_dataframe_test.py
+++ b/tests/events/event_dataframe_test.py
@@ -235,3 +235,10 @@ def test_event_dataframe_add_property_has_expected_result(
     event_df.add_property(**property_kwargs)
 
     assert_frame_equal(event_df.frame, expected_df.frame)
+
+
+def test_event_dataframe_columns_same_as_frame():
+    init_kwargs = {'onsets': [0], 'offsets': [1]}
+    event_df = EventDataFrame(**init_kwargs)
+
+    assert event_df.columns == event_df.frame.columns
diff --git a/tests/events/idt_test.py b/tests/events/idt_test.py
index 6edc32e..3a31f65 100644
--- a/tests/events/idt_test.py
+++ b/tests/events/idt_test.py
@@ -211,6 +211,20 @@ def test_idt_raises_error(kwargs, expected_error):
             ),
             id='one_fixation_nan_delete_leading_ending',
         ),
+        pytest.param(
+            {
+                'positions': step_function(length=100, steps=[0], values=[(0, 0)]),
+                'timesteps': np.arange(1000, 1100, dtype=int),
+                'dispersion_threshold': 1,
+                'minimum_duration': 1,
+            },
+            EventDataFrame(
+                name='fixation',
+                onsets=[1000],
+                offsets=[1099],
+            ),
+            id='constant_position_single_fixation_with_timesteps',
+        ),
     ],
 )
 def test_idt_detects_fixations(kwargs, expected):
@@ -219,3 +233,40 @@ def test_idt_detects_fixations(kwargs, expected):
     events = idt(velocities=velocities, **kwargs)
 
     assert_frame_equal(events.frame, expected.frame)
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'exception', 'msg_substrings'),
+    [
+        pytest.param(
+            {
+                'positions': step_function(length=10, steps=[0], values=[(0, 0)]),
+                'timesteps': np.concatenate([
+                    np.arange(0, 5, dtype=int), np.arange(7, 12, dtype=int),
+                ]),
+                'dispersion_threshold': 1,
+                'minimum_duration': 1,
+            },
+            ValueError, ('interval', 'timesteps', 'constant'),
+            id='non_constant_timesteps_interval',
+        ),
+        pytest.param(
+            {
+                'positions': step_function(length=10, steps=[0], values=[(0, 0)]),
+                'timesteps': np.arange(0, 30, step=3, dtype=int),
+                'dispersion_threshold': 1,
+                'minimum_duration': 2,
+            },
+            ValueError, ('interval', 'timesteps', 'divisible', 'minimum_duration'),
+            id='minimum_duration_not_divisible_by_timesteps_interval',
+        ),
+    ],
+)
+def test_idt_timesteps_exceptions(kwargs, exception, msg_substrings):
+    velocities = pos2vel(kwargs['positions'], sampling_rate=10, method='preceding')
+    with pytest.raises(exception) as excinfo:
+        idt(velocities=velocities, **kwargs)
+
+    msg, = excinfo.value.args
+    for msg_substring in msg_substrings:
+        assert msg_substring.lower() in msg.lower()
diff --git a/tests/events/ivt_test.py b/tests/events/ivt_test.py
index 70281ba..adceb84 100644
--- a/tests/events/ivt_test.py
+++ b/tests/events/ivt_test.py
@@ -243,6 +243,20 @@ def test_ivt_raise_error(kwargs, expected_error):
             ),
             id='one_fixation_nan_remove_leading_ending',
         ),
+        pytest.param(
+            {
+                'positions': step_function(length=100, steps=[0], values=[(0, 0)]),
+                'timesteps': np.arange(1000, 1100, dtype=int),
+                'velocity_threshold': 1,
+                'minimum_duration': 1,
+            },
+            EventDataFrame(
+                name='fixation',
+                onsets=[1000],
+                offsets=[1099],
+            ),
+            id='constant_position_single_fixation_with_timesteps',
+        ),
     ],
 )
 def test_ivt_detects_fixations(kwargs, expected):