From 2a253a793846236623385e9c67c6afa80d7b77ab Mon Sep 17 00:00:00 2001 From: Aman Srivastava Date: Tue, 7 Apr 2026 09:47:16 +0530 Subject: [PATCH 1/2] add description filter parameter --- mne/io/base.py | 13 +++++++++--- mne/io/tests/test_raw.py | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/mne/io/base.py b/mne/io/base.py index b07a45e9927..8e1463d137d 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -1703,7 +1703,7 @@ def crop( return self @verbose - def crop_by_annotations(self, annotations=None, *, verbose=None): + def crop_by_annotations(self, annotations=None, description=None, *, verbose=None): """Get crops of raw data file for selected annotations. Parameters @@ -1711,6 +1711,9 @@ def crop_by_annotations(self, annotations=None, *, verbose=None): annotations : instance of Annotations | None The annotations to use for cropping the raw file. If None, the annotations from the instance are used. + description : str | list of str | None + If not None, only annotations with matching descriptions will + be used for cropping. If None (default), all annotations are used. %(verbose)s Returns @@ -1721,11 +1724,15 @@ def crop_by_annotations(self, annotations=None, *, verbose=None): if annotations is None: annotations = self.annotations + if description is not None: + if isinstance(description, str): + description = [description] + mask = np.isin(annotations.description, description) + annotations = annotations[mask] + raws = [] for annot in annotations: onset = annot["onset"] - self.first_time - # be careful about near-zero errors (crop is very picky about this, - # e.g., -1e-8 is an error) if -self.info["sfreq"] / 2 < onset < 0: onset = 0 raw_crop = self.copy().crop(onset, onset + annot["duration"]) diff --git a/mne/io/tests/test_raw.py b/mne/io/tests/test_raw.py index bb6d58ee0d9..dacabd33551 100644 --- a/mne/io/tests/test_raw.py +++ b/mne/io/tests/test_raw.py @@ -661,6 +661,50 @@ def test_crop_by_annotations(meas_date, first_samp): assert raws[1].annotations.description[0] == annot.description[1] +@pytest.mark.parametrize("meas_date", [None, "orig"]) +@pytest.mark.parametrize("first_samp", [0, 10000]) +def test_crop_by_annotations_description(meas_date, first_samp): + """Test crop_by_annotations with description filter.""" + raw = read_raw_fif(raw_fname) + + if meas_date is None: + raw.set_meas_date(None) + + raw = mne.io.RawArray(raw.get_data(), raw.info, first_samp=first_samp) + + onset = np.array([0, 1.5, 2.5], float) + if meas_date is not None: + onset += raw.first_time + annot = mne.Annotations( + onset=onset, + duration=[1, 0.5, 0.5], + description=["stimulus", "bad", "stimulus"], + orig_time=raw.info["meas_date"], + ) + raw.set_annotations(annot) + + # filter by single string + raws = raw.crop_by_annotations(description="stimulus") + assert len(raws) == 2 + assert all(r.annotations.description[0] == "stimulus" for r in raws) + + # filter by list + raws = raw.crop_by_annotations(description=["stimulus"]) + assert len(raws) == 2 + + # filter by multiple descriptions + raws = raw.crop_by_annotations(description=["stimulus", "bad"]) + assert len(raws) == 3 + + # filter with no match returns empty list + raws = raw.crop_by_annotations(description="nonexistent") + assert len(raws) == 0 + + # None returns all (default behavior unchanged) + raws = raw.crop_by_annotations() + assert len(raws) == 3 + + @pytest.mark.parametrize( "offset, origin", [ From d404dcf4753fc3569488c5398c468c73c44e0847 Mon Sep 17 00:00:00 2001 From: Aman Srivastava Date: Thu, 9 Apr 2026 11:14:52 +0530 Subject: [PATCH 2/2] use np.isin for exact matching and add RuntimeWarning on no match --- doc/changes/dev/13820.newfeature.rst | 1 + mne/io/base.py | 7 ++++++- mne/io/tests/test_raw.py | 6 ++++-- 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 doc/changes/dev/13820.newfeature.rst diff --git a/doc/changes/dev/13820.newfeature.rst b/doc/changes/dev/13820.newfeature.rst new file mode 100644 index 00000000000..1dc3f80a805 --- /dev/null +++ b/doc/changes/dev/13820.newfeature.rst @@ -0,0 +1 @@ +Added a ``description`` parameter to :meth:`mne.io.Raw.crop_by_annotations` to filter crops by annotation description, by `Aman Srivastava`_. \ No newline at end of file diff --git a/mne/io/base.py b/mne/io/base.py index 8e1463d137d..639bdf15bc6 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -1712,7 +1712,7 @@ def crop_by_annotations(self, annotations=None, description=None, *, verbose=Non The annotations to use for cropping the raw file. If None, the annotations from the instance are used. description : str | list of str | None - If not None, only annotations with matching descriptions will + If not None, only annotations whose descriptions exactly match will be used for cropping. If None (default), all annotations are used. %(verbose)s @@ -1729,6 +1729,11 @@ def crop_by_annotations(self, annotations=None, description=None, *, verbose=Non description = [description] mask = np.isin(annotations.description, description) annotations = annotations[mask] + if len(annotations) == 0: + warn( + f"No annotations found matching description(s): {description}", + RuntimeWarning, + ) raws = [] for annot in annotations: diff --git a/mne/io/tests/test_raw.py b/mne/io/tests/test_raw.py index dacabd33551..60fc4f3c794 100644 --- a/mne/io/tests/test_raw.py +++ b/mne/io/tests/test_raw.py @@ -691,13 +691,15 @@ def test_crop_by_annotations_description(meas_date, first_samp): # filter by list raws = raw.crop_by_annotations(description=["stimulus"]) assert len(raws) == 2 + assert all(r.annotations.description[0] == "stimulus" for r in raws) # filter by multiple descriptions raws = raw.crop_by_annotations(description=["stimulus", "bad"]) assert len(raws) == 3 - # filter with no match returns empty list - raws = raw.crop_by_annotations(description="nonexistent") + # filter with no match returns empty list and warns + with pytest.warns(RuntimeWarning, match="No annotations found"): + raws = raw.crop_by_annotations(description="nonexistent") assert len(raws) == 0 # None returns all (default behavior unchanged)