Repository: mne-tools/mne-python

Issue:
Add tolerance parameter to events_from_annotations  to 
### Describe the new feature or enhancement

Add `tol` parameter to `annotations_from_events` to avoid omitting epochs by rounding errors.

### Describe your proposed implementation

In the function named events_from_annotations, there is a section of code as follows:

```
for annot in annotations[event_sel]:
      annot_offset = annot["onset"] + annot["duration"]
      _onsets = np.arange(
          start=annot["onset"], stop=annot_offset, step=chunk_duration
      )
      good_events = annot_offset - _onsets >= chunk_duration 
      if good_events.any():
```

However, if we consider values like

```
annot['onset']=32760.12
annot['duration']=30.0
chunk_duration=30.0
```

In theory, this region should be obtained as a 30-second epoch, but due to rounding errors, it results in:

```
>>> annot_offset = annot['onset'] + annot['duration']
>>> print(annot_offset - np.arange(annot['onset'], annot_offset,  30.0) - 30.0)
array([-3.63797881e-12])
```

This causes good_events to contain False, and thus, this epoch is not captured.

However, in reality, this interval's data should be fully treated as an epoch. Therefore, I'm considering introducing a tolerance and modifying the code like this:

```
good_events = annot_offset - _onsets >= chunk_duration + tol
```

By adding a small tol, we can avoid the situation mentioned above. If tol is sufficiently smaller than the frequency, it should not erroneously include intervals that are not supposed to be selected.
This is safe as long as `use_rounding = True`.

If `use_rounding = False`, the first data of the epoch could be overlapped with the previous epoch. (if `tol = 0`, such an epoch is not created.)
But even this case, I think it is better to keep the epoch.

By setting the default value of tol to 0, we can maintain the same results as currently obtained, as long as it is used in the same way as now.

### Describe possible alternatives

Currently, I am using Annotations that are precisely segmented into 30-second intervals. By setting chunk_duration = 0, I can create indexes without needing to calculate the duration.

This approach ensures that all epochs are included in the output data.

However, sometimes the annotations have durations that are multiple times the length of an epoch. In these cases, we need to modify the annotations before applying them to the Raw data.

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/mne/annotations.py b/mne/annotations.py
--- a/mne/annotations.py
+++ b/mne/annotations.py
@@ -1529,6 +1529,7 @@ def events_from_annotations(
     regexp=r"^(?![Bb][Aa][Dd]|[Ee][Dd][Gg][Ee]).*$",
     use_rounding=True,
     chunk_duration=None,
+    tol=1e-8,
     verbose=None,
 ):
     """Get :term:`events` and ``event_id`` from an Annotations object.
@@ -1572,6 +1573,11 @@ def events_from_annotations(
         they fit within the annotation duration spaced according to
         ``chunk_duration``. As a consequence annotations with duration shorter
         than ``chunk_duration`` will not contribute events.
+    tol : float
+        The tolerance used to check if a chunk fits within an annotation when
+        ``chunk_duration`` is not ``None``. If the duration from a computed
+        chunk onset to the end of the annotation is smaller than
+        ``chunk_duration`` minus ``tol``, the onset will be discarded.
     %(verbose)s
 
     Returns
@@ -1617,7 +1623,7 @@ def events_from_annotations(
         for annot in annotations[event_sel]:
             annot_offset = annot["onset"] + annot["duration"]
             _onsets = np.arange(annot["onset"], annot_offset, chunk_duration)
-            good_events = annot_offset - _onsets >= chunk_duration
+            good_events = annot_offset - _onsets >= chunk_duration - tol
             if good_events.any():
                 _onsets = _onsets[good_events]
                 _inds = raw.time_as_index(


Test patch:
diff --git a/mne/tests/test_annotations.py b/mne/tests/test_annotations.py
--- a/mne/tests/test_annotations.py
+++ b/mne/tests/test_annotations.py
@@ -819,6 +819,49 @@ def test_events_from_annot_onset_alingment():
     assert raw.first_samp == event_latencies[0, 0]
 
 
+@pytest.mark.parametrize(
+    "use_rounding,tol,shape,onsets,descriptions",
+    [
+        pytest.param(True, 0, (2, 3), [202, 402], [0, 2], id="rounding-notol"),
+        pytest.param(True, 1e-8, (3, 3), [202, 302, 402], [0, 1, 2], id="rounding-tol"),
+        pytest.param(False, 0, (2, 3), [202, 401], [0, 2], id="norounding-notol"),
+        pytest.param(
+            False, 1e-8, (3, 3), [202, 302, 401], [0, 1, 2], id="norounding-tol"
+        ),
+        pytest.param(None, None, (3, 3), [202, 302, 402], [0, 1, 2], id="default"),
+    ],
+)
+def test_events_from_annot_with_tolerance(
+    use_rounding, tol, shape, onsets, descriptions
+):
+    """Test events_from_annotations w/ and w/o tolerance."""
+    info = create_info(ch_names=1, sfreq=100)
+    raw = RawArray(data=np.empty((1, 1000)), info=info, first_samp=0)
+    meas_date = _handle_meas_date(0)
+    with raw.info._unlock(check_after=True):
+        raw.info["meas_date"] = meas_date
+    chunk_duration = 1
+    annot = Annotations([2.02, 3.02, 4.02], chunk_duration, ["0", "1", "2"], 0)
+    raw.set_annotations(annot)
+    event_id = {"0": 0, "1": 1, "2": 2}
+
+    if use_rounding is None:
+        events, _ = events_from_annotations(
+            raw, event_id=event_id, chunk_duration=chunk_duration
+        )
+    else:
+        events, _ = events_from_annotations(
+            raw,
+            event_id=event_id,
+            chunk_duration=chunk_duration,
+            use_rounding=use_rounding,
+            tol=tol,
+        )
+    assert events.shape == shape
+    assert (events[:, 0] == onsets).all()
+    assert (events[:, 2] == descriptions).all()
+
+
 def _create_annotation_based_on_descr(
     description, annotation_start_sampl=0, duration=0, orig_time=0
 ):