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:
agramfort: the issue you report in #12321 suggests it's a silent bug that can happen quiet easily. I fear that with just the docstring you have here, users will unlikely do the right thing. Do you see a way to have a better default? Do you see a case where using tol=1e-15 can lead to some unexpected behavior?

do you think there is bug in this tutorial https://mne.tools/stable/auto_tutorials/clinical/60_sleep.html ?
rcmdnk: @agramfort 
Thanks for the comment.

The example case needs at least tol of 1e-11:

```
>>> onset=32760.12
>>> duration=30.0
>>> chunk_duration=30.0
>>> onset + duration - onset - chunk_duration
-3.637978807091713e-12
```

I think something like 1e-8 is enough large for the tolerance
and also enough small comparing to the interval of data (for frequency up to O(100Hz) at most).

I updated the default value of `tol = 1e-8`.

For the tutorial, Both `meas_date` of Raw (then it is the orig_time of the annotation) and all onset of Annotations are not decimals and it is not affected by this issue.
agramfort: Ok I fully get the issue now. Note that this reminds me of:

```
>>> np.arange(7.8, 8.4, 0.05)
array([7.8 , 7.85, 7.9 , 7.95, 8.  , 8.05, 8.1 , 8.15, 8.2 , 8.25, 8.3 ,
       8.35, 8.4 ])
```

basically when you have a float step size you have rounding errors. Here it's created cause there is a loss of accuracy with the meas_date but it feels it can happen also without.

what makes me less supportive of this change is that if you use the public API ie `raw.set_annotations(annot)` then the issue does not seem to happen.
agramfort: @rcmdnk sorry my last comment about using raw.set_annotations had not been sent.
rcmdnk: @agramfort,

Apologies for the delayed response.

To clarify, the previous test would not have encountered any loss if set_annotations were used, because the orig_time of the annotation is modified within this function. The reason I opted not to use set_annotations is due to my choice of 32730.12 as the onset value. Starting from 0 with this onset value would require a significantly large dataset (on the order of 10,000,000).

However, if the meas_date of the raw data and the orig_time of the annotations are identical, using set_annotations won't alter the onsets, and the original issue persists. Furthermore, if the duration includes a decimal value, the same problem can arise.

I've simplified the test to demonstrate that using set_annotations with a tol value can effectively address the issue as anticipated.
larsoner: @rcmdnk sorry for the hassle but it looks like there is a conflict, could you `git fetch upstream && git merge upstream main` and fix the conflicts (or rebase if you prefer)?
rcmdnk: @larsoner
ok, the branch is rebased on main now. 

Patch:
diff --git a/doc/changes/devel/12324.bugfix.rst b/doc/changes/devel/12324.bugfix.rst
new file mode 100644
index 000000000..ec7f2c584
--- /dev/null
+++ b/doc/changes/devel/12324.bugfix.rst
@@ -0,0 +1,1 @@
+Add ``tol`` parameter to :meth:`mne.events_from_annotations` so that the user can specify the tolerance to ignore rounding errors of event onsets when using ``chunk_duration`` is not None (default is 1e-8), by `Michiru Kaneda`_
diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py
index 462a34cb6..797e3d4bb 100644
--- a/mne/_fiff/meas_info.py
+++ b/mne/_fiff/meas_info.py
@@ -1104,9 +1104,9 @@ class Info(dict, SetChannelsMixin, MontageMixin, ContainsMixin):
         The transformation from 4D/CTF head coordinates to Neuromag head
         coordinates. This is only present in 4D/CTF data.
     custom_ref_applied : int
-        Whether a custom (=other than average) reference has been applied to
-        the EEG data. This flag is checked by some algorithms that require an
-        average reference to be set.
+        Whether a custom (=other than an average projector) reference has been
+        applied to the EEG data. This flag is checked by some algorithms that
+        require an average reference to be set.
     description : str | None
         String description of the recording.
     dev_ctf_t : Transform | None
diff --git a/mne/annotations.py b/mne/annotations.py
index f0f88783b..a6be1f7a6 100644
--- 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(
diff --git a/pyproject.toml b/pyproject.toml
index 58a5915cd..998ceffc5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -205,8 +205,10 @@ builtin = "clear,rare,informal,names,usage"
 skip = "doc/references.bib"
 
 [tool.ruff]
-select = ["A", "B006", "D", "E", "F", "I", "W", "UP"]
 exclude = ["__init__.py", "constants.py", "resources.py"]
+
+[tool.ruff.lint]
+select = ["A", "B006", "D", "E", "F", "I", "W", "UP"]
 ignore = [
     "D100", # Missing docstring in public module
     "D104", # Missing docstring in public package
@@ -214,7 +216,7 @@ ignore = [
     "UP031", # Use format specifiers instead of percent format
 ]
 
-[tool.ruff.pydocstyle]
+[tool.ruff.lint.pydocstyle]
 convention = "numpy"
 ignore-decorators = [
     "property",
@@ -224,7 +226,7 @@ ignore-decorators = [
     "mne.utils.deprecated",
 ]
 
-[tool.ruff.per-file-ignores]
+[tool.ruff.lint.per-file-ignores]
 "tutorials/time-freq/10_spectrum_class.py" = [
     "E501", # line too long
 ]


Test patch:
diff --git a/mne/tests/test_annotations.py b/mne/tests/test_annotations.py
index 8be52b60a..4868f5dc5 100644
--- 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
 ):