From 2f91fdd90e235ecead0cc48b3626bfa7ee21aa36 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 4 Feb 2026 20:32:24 +0100 Subject: [PATCH 1/6] Fix: Rounding error in get_data with tmin/tmax (GH-13634) --- mne/tests/test_epochs.py | 24 ++++++++++++++++++++++++ mne/utils/mixin.py | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index 91c5f902ac8..52d319de2d9 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -4855,6 +4855,30 @@ def fun(data): assert_array_equal(out.get_data(non_picks), epochs.get_data(non_picks)) +def test_get_data_rounding(): + """Test that get_data respects rounding for tmin/tmax (gh-13634).""" + # Data setup mirroring the issue report + data = np.linspace(-3.5, 1, 451).reshape((1, 1, 451)) + info = create_info(['test'], 100., 'eeg') + epochs = EpochsArray(data, info, tmin=-3.5, verbose=False) + + t = 0.77 + + # compare crop() vs get_data() + # crop() uses proper rounding internally, get_data() should match it. + val_crop = epochs.copy().crop(tmin=t).get_data()[0, 0, 0] + val_get_data = epochs.get_data(tmin=t)[0, 0, 0] + + assert_allclose(val_get_data, val_crop, atol=1e-12, err_msg="get_data(tmin) does not match crop(tmin)") + + # verification on time consistency + # ensure we are getting the sample corresponding exactly to time 't' + idx = np.where(epochs.times == t)[0][0] + val_direct = epochs.get_data()[0, 0, idx] + + assert_allclose(val_get_data, val_direct, atol=1e-12, err_msg="get_data(tmin) does not match direct indexing") + + def test_apply_function_epo_ch_access(): """Test ch-access within apply function to epoch objects.""" diff --git a/mne/utils/mixin.py b/mne/utils/mixin.py index 741636115e2..a9d270df84c 100644 --- a/mne/utils/mixin.py +++ b/mne/utils/mixin.py @@ -553,8 +553,8 @@ def _handle_tmin_tmax(self, tmin, tmax): # handle tmin/tmax as start and stop indices into data array n_times = self.times.size - start = 0 if tmin is None else self.time_as_index(tmin)[0] - stop = n_times if tmax is None else self.time_as_index(tmax)[0] + start = 0 if tmin is None else self.time_as_index(tmin, use_rounding=True)[0] + stop = n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] # truncate start/stop to the open interval [0, n_times] start = min(max(0, start), n_times) From d4593842e28e8f3d37348997656efdd7803f9924 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:44:23 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/tests/test_epochs.py | 16 +++++++++++++--- mne/utils/mixin.py | 4 +++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index 52d319de2d9..6513e76b167 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -4859,7 +4859,7 @@ def test_get_data_rounding(): """Test that get_data respects rounding for tmin/tmax (gh-13634).""" # Data setup mirroring the issue report data = np.linspace(-3.5, 1, 451).reshape((1, 1, 451)) - info = create_info(['test'], 100., 'eeg') + info = create_info(["test"], 100.0, "eeg") epochs = EpochsArray(data, info, tmin=-3.5, verbose=False) t = 0.77 @@ -4869,14 +4869,24 @@ def test_get_data_rounding(): val_crop = epochs.copy().crop(tmin=t).get_data()[0, 0, 0] val_get_data = epochs.get_data(tmin=t)[0, 0, 0] - assert_allclose(val_get_data, val_crop, atol=1e-12, err_msg="get_data(tmin) does not match crop(tmin)") + assert_allclose( + val_get_data, + val_crop, + atol=1e-12, + err_msg="get_data(tmin) does not match crop(tmin)", + ) # verification on time consistency # ensure we are getting the sample corresponding exactly to time 't' idx = np.where(epochs.times == t)[0][0] val_direct = epochs.get_data()[0, 0, idx] - assert_allclose(val_get_data, val_direct, atol=1e-12, err_msg="get_data(tmin) does not match direct indexing") + assert_allclose( + val_get_data, + val_direct, + atol=1e-12, + err_msg="get_data(tmin) does not match direct indexing", + ) def test_apply_function_epo_ch_access(): diff --git a/mne/utils/mixin.py b/mne/utils/mixin.py index a9d270df84c..6cebc693e61 100644 --- a/mne/utils/mixin.py +++ b/mne/utils/mixin.py @@ -554,7 +554,9 @@ def _handle_tmin_tmax(self, tmin, tmax): # handle tmin/tmax as start and stop indices into data array n_times = self.times.size start = 0 if tmin is None else self.time_as_index(tmin, use_rounding=True)[0] - stop = n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] + stop = ( + n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] + ) # truncate start/stop to the open interval [0, n_times] start = min(max(0, start), n_times) From 3995e9b060f0eb8703c6928f63129df5d95a46e6 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 4 Feb 2026 22:47:10 +0100 Subject: [PATCH 3/6] Refactor: Move fix to BaseEpochs to fix Raw regression --- mne/epochs.py | 28 ++++++++++++++++++++++++++++ mne/utils/mixin.py | 6 ++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index 2d317caa63e..3f025ede729 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -1595,6 +1595,34 @@ def _handle_empty(self, on_empty, meth): ) _on_missing(on_empty, msg, error_klass=RuntimeError) + def _handle_tmin_tmax(self, tmin, tmax): + """Convert seconds to index into data.""" + + _validate_type( + tmin, + types=("numeric", None), + item_name="tmin", + type_name="int, float, None", + ) + _validate_type( + tmax, + types=("numeric", None), + item_name="tmax", + type_name="int, float, None", + ) + + # handle tmin/tmax as start and stop indices into data array + n_times = self.times.size + # QUI c'è la fix specifica per le Epochs + start = 0 if tmin is None else self.time_as_index(tmin, use_rounding=True)[0] + stop = n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] + + # truncate start/stop to the open interval [0, n_times] + start = min(max(0, start), n_times) + stop = min(max(0, stop), n_times) + + return start, stop + @verbose def _get_data( self, diff --git a/mne/utils/mixin.py b/mne/utils/mixin.py index 6cebc693e61..741636115e2 100644 --- a/mne/utils/mixin.py +++ b/mne/utils/mixin.py @@ -553,10 +553,8 @@ def _handle_tmin_tmax(self, tmin, tmax): # handle tmin/tmax as start and stop indices into data array n_times = self.times.size - start = 0 if tmin is None else self.time_as_index(tmin, use_rounding=True)[0] - stop = ( - n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] - ) + start = 0 if tmin is None else self.time_as_index(tmin)[0] + stop = n_times if tmax is None else self.time_as_index(tmax)[0] # truncate start/stop to the open interval [0, n_times] start = min(max(0, start), n_times) From a7746ad3f90c5ae157ca7380a453704143a12b8b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:48:00 +0000 Subject: [PATCH 4/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/epochs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index 3f025ede729..1c20ae2efa1 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -1597,7 +1597,6 @@ def _handle_empty(self, on_empty, meth): def _handle_tmin_tmax(self, tmin, tmax): """Convert seconds to index into data.""" - _validate_type( tmin, types=("numeric", None), @@ -1615,7 +1614,9 @@ def _handle_tmin_tmax(self, tmin, tmax): n_times = self.times.size # QUI c'è la fix specifica per le Epochs start = 0 if tmin is None else self.time_as_index(tmin, use_rounding=True)[0] - stop = n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] + stop = ( + n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] + ) # truncate start/stop to the open interval [0, n_times] start = min(max(0, start), n_times) From fbb02fe62366290dca67cde60ea0ca16ffb6fc56 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 4 Feb 2026 23:10:46 +0100 Subject: [PATCH 5/6] Style: Remove Italian comment --- mne/epochs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index 1c20ae2efa1..2cdc733cc3e 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -1612,11 +1612,9 @@ def _handle_tmin_tmax(self, tmin, tmax): # handle tmin/tmax as start and stop indices into data array n_times = self.times.size - # QUI c'è la fix specifica per le Epochs start = 0 if tmin is None else self.time_as_index(tmin, use_rounding=True)[0] - stop = ( - n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] - ) + stop = n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] + # truncate start/stop to the open interval [0, n_times] start = min(max(0, start), n_times) From 6831f80b3d7af4213e1d3ec0d90e6ead80a351bf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:11:24 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/epochs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mne/epochs.py b/mne/epochs.py index 2cdc733cc3e..184a0f8a80b 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -1613,8 +1613,9 @@ def _handle_tmin_tmax(self, tmin, tmax): # handle tmin/tmax as start and stop indices into data array n_times = self.times.size start = 0 if tmin is None else self.time_as_index(tmin, use_rounding=True)[0] - stop = n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] - + stop = ( + n_times if tmax is None else self.time_as_index(tmax, use_rounding=True)[0] + ) # truncate start/stop to the open interval [0, n_times] start = min(max(0, start), n_times)