diff --git a/doc/changes/dev/13892.bugfix.rst b/doc/changes/dev/13892.bugfix.rst new file mode 100644 index 00000000000..2d295837cb6 --- /dev/null +++ b/doc/changes/dev/13892.bugfix.rst @@ -0,0 +1 @@ +Fix :meth:`mne.Annotations.__getitem__` (i.e., ``raw.annotations[i]["onset"]``) to return onset times in ``raw.times`` reference (0-indexed from ``first_samp``) instead of ``meas_date``-relative time, making the value directly usable with :meth:`mne.io.BaseRaw.get_data` and :meth:`mne.io.BaseRaw.plot`, by `Sanskaar Undale`_. diff --git a/doc/changes/names.inc b/doc/changes/names.inc index e20fad63acb..aca1242c3bf 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -301,6 +301,7 @@ .. _Samuel Deslauriers-Gauthier: https://github.com/sdeslauriers .. _Samuel Louviot: https://github.com/Sam54000 .. _Samuel Powell: https://github.com/samuelpowell +.. _Sanskaar Undale: https://github.com/SanskaarUndale21 .. _Santeri Ruuskanen: https://github.com/ruuskas .. _Santi Martínez: https://github.com/szz-dvl .. _Sara Sommariva: https://github.com/sarasommariva diff --git a/mne/annotations.py b/mne/annotations.py index c03e0610f28..6a97bfff99e 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -512,8 +512,12 @@ def __getitem__(self, key, *, with_ch_names=None, with_extras=True): """Propagate indexing and slicing to the underlying numpy structure.""" if isinstance(key, int_like): out_keys = ("onset", "duration", "description", "orig_time") + # When attached to a Raw object, subtract _raw_first_time so that + # ann["onset"] is in raw.times reference (usable directly with + # raw.get_data(tmin=...) and raw.plot(start=...)). + _raw_ft = getattr(self, "_raw_first_time", 0.0) out_vals = ( - self.onset[key], + self.onset[key] - _raw_ft, self.duration[key], self.description[key], self.orig_time, @@ -527,7 +531,7 @@ def __getitem__(self, key, *, with_ch_names=None, with_extras=True): return OrderedDict(zip(out_keys, out_vals)) else: key = list(key) if isinstance(key, tuple) else key - return Annotations( + result = Annotations( onset=self.onset[key], duration=self.duration[key], description=self.description[key], @@ -535,6 +539,11 @@ def __getitem__(self, key, *, with_ch_names=None, with_extras=True): ch_names=self.ch_names[key], extras=[self.extras[i] for i in np.arange(len(self.extras))[key]], ) + # Propagate the raw-times reference tag so that sliced annotations + # still return onsets in raw.times reference. + if hasattr(self, "_raw_first_time"): + result._raw_first_time = self._raw_first_time + return result @fill_doc def append(self, onset, duration, description, ch_names=None, *, extras=None): @@ -1497,11 +1506,15 @@ def get_annotations_per_epoch(self, *, with_extras=False): # for each Epoch-Annotation overlap occurrence: for annot_ix, epo_ix in zip(*np.nonzero(all_cases)): + # Use the raw onset array (internal reference) so the relative + # onset calculation stays consistent with epoch_tzeros, which is + # also in the internal (meas_date-relative) reference frame. + this_onset = self._annotations.onset[annot_ix] this_annot = self._annotations[annot_ix] this_tzero = epoch_tzeros[epo_ix] # adjust annotation onset to be relative to epoch tzero... annot = ( - this_annot["onset"] - this_tzero, + this_onset - this_tzero, this_annot["duration"], this_annot["description"], ) @@ -2370,8 +2383,10 @@ def events_from_annotations( good_events = annot_offset - _onsets >= chunk_duration - tol if good_events.any(): _onsets = _onsets[good_events] + # annot["onset"] is in raw.times reference (origin=None), + # not meas_date reference, so use origin=None here. _inds = raw.time_as_index( - _onsets, use_rounding=use_rounding, origin=annotations.orig_time + _onsets, use_rounding=use_rounding, origin=None ) _inds += raw.first_samp inds = np.append(inds, _inds) diff --git a/mne/export/_brainvision.py b/mne/export/_brainvision.py index 6503c540f41..2fb2becc1b5 100644 --- a/mne/export/_brainvision.py +++ b/mne/export/_brainvision.py @@ -118,7 +118,7 @@ def _mne_annots2pybv_events(raw): for annot in raw.annotations: # handle onset and duration: seconds to sample, relative to # raw.first_samp / raw.first_time - onset = annot["onset"] - raw.first_time + onset = annot["onset"] onset = raw.time_as_index(onset).astype(int)[0] duration = int(annot["duration"] * raw.info["sfreq"]) diff --git a/mne/io/base.py b/mne/io/base.py index 617ae66d302..0245f70093a 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -765,6 +765,10 @@ def set_annotations( meas_date - new_annotations.orig_time ).total_seconds() new_annotations._orig_time = meas_date + # Tag with _raw_first_time so the public onset accessor can return + # values in raw.times reference (0-indexed from first_samp) instead + # of the internal meas_date-relative reference. + new_annotations._raw_first_time = self._first_time self._annotations = new_annotations @@ -1730,7 +1734,9 @@ def crop_by_annotations(self, annotations=None, *, verbose=None): raws = [] for annot in annotations: - onset = annot["onset"] - self.first_time + # annot["onset"] is already in raw.times reference (0-indexed from + # first_samp) when the annotation comes from raw.annotations. + onset = annot["onset"] # 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: