Skip to content

Commit 7c0b74a

Browse files
Famous077pre-commit-ci[bot]cbrnrlarsonerscott-huberty
authored
DOC: document onset, duration, description, and ch_names attributes of mne.Annotations (#13680)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Clemens Brunner <clemens.brunner@gmail.com> Co-authored-by: Eric Larson <larson.eric.d@gmail.com> Co-authored-by: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Co-authored-by: Scott Huberty <seh33@uw.edu>
1 parent 84ca609 commit 7c0b74a

3 files changed

Lines changed: 211 additions & 53 deletions

File tree

doc/changes/dev/13680.other.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Document :attr:`~mne.Annotations.onset`, :attr:`~mne.Annotations.duration`, :attr:`~mne.Annotations.description`, and :attr:`~mne.Annotations.ch_names` attributes of :class:`mne.Annotations`, by `Famous077`_.

mne/annotations.py

Lines changed: 175 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -158,48 +158,72 @@ def _validate_extras(extras, length: int):
158158
return _AnnotationsExtrasList(extras or [None] * length)
159159

160160

161-
def _check_o_d_s_c_e(onset, duration, description, ch_names, extras):
162-
onset = np.atleast_1d(np.array(onset, dtype=float))
163-
if onset.ndim != 1:
164-
raise ValueError(
165-
f"Onset must be a one dimensional array, got {onset.ndim} (shape "
166-
f"{onset.shape})."
167-
)
168-
duration = np.array(duration, dtype=float)
169-
if duration.ndim == 0 or duration.shape == (1,):
170-
duration = np.repeat(duration, len(onset))
171-
if duration.ndim != 1:
161+
def _ensure_1d(arr, *, dtype, name):
162+
arr = np.atleast_1d(np.array(arr, dtype=dtype))
163+
if arr.ndim != 1:
172164
raise ValueError(
173-
f"Duration must be a one dimensional array, got {duration.ndim}."
165+
f"{name} must be a one dimensional array, got {arr.ndim} (shape "
166+
f"{arr.shape})."
174167
)
168+
return arr
175169

176-
description = np.array(description, dtype=str)
177-
if description.ndim == 0 or description.shape == (1,):
178-
description = np.repeat(description, len(onset))
179-
if description.ndim != 1:
170+
171+
def _check_length(arr, n, *, name):
172+
if len(arr) != n:
180173
raise ValueError(
181-
f"Description must be a one dimensional array, got {description.ndim}."
174+
f"Length of {name} ({len(arr)}) must match the length of "
175+
f"existing annotations ({n})."
182176
)
177+
178+
179+
def _check_onset(onset, n=None):
180+
"""Convert and validate onset to a 1D float array."""
181+
onset = _ensure_1d(onset, dtype=float, name="onset")
182+
if n is not None:
183+
_check_length(onset, n, name="onset")
184+
return onset
185+
186+
187+
def _check_duration(duration, n):
188+
"""Convert and validate duration to a 1D float array of length n."""
189+
duration = _ensure_1d(duration, dtype=float, name="duration")
190+
if duration.shape == (1,):
191+
duration = np.repeat(duration, n)
192+
_check_length(duration, n, name="duration")
193+
return duration
194+
195+
196+
def _check_description(description, n):
197+
"""Convert and validate description to a 1D str array of length n."""
198+
description = _ensure_1d(description, dtype=str, name="description")
199+
if description.shape == (1,):
200+
description = np.repeat(description, n)
201+
_check_length(description, n, name="description")
183202
_safe_name_list(description, "write", "description")
203+
return description
204+
184205

185-
# ch_names: convert to ndarray of tuples
206+
def _check_ch_names_annot(ch_names, n):
207+
"""Convert and validate ch_names to an ndarray of tuples of length n."""
186208
_validate_type(ch_names, (None, tuple, list, np.ndarray), "ch_names")
187209
if ch_names is None:
188-
ch_names = [()] * len(onset)
210+
ch_names = [()] * n
189211
ch_names = list(ch_names)
212+
_check_length(ch_names, n, name="ch_names")
190213
for ai, ch in enumerate(ch_names):
191214
_validate_type(ch, (list, tuple, np.ndarray), f"ch_names[{ai}]")
192215
ch_names[ai] = tuple(ch)
193216
for ci, name in enumerate(ch_names[ai]):
194217
_validate_type(name, str, f"ch_names[{ai}][{ci}]")
195-
ch_names = _ndarray_ch_names(ch_names)
218+
return _ndarray_ch_names(ch_names)
196219

197-
if not (len(onset) == len(duration) == len(description) == len(ch_names)):
198-
raise ValueError(
199-
"Onset, duration, description, and ch_names must be "
200-
f"equal in sizes, got {len(onset)}, {len(duration)}, "
201-
f"{len(description)}, and {len(ch_names)}."
202-
)
220+
221+
def _check_o_d_s_c_e(onset, duration, description, ch_names, extras):
222+
onset = _check_onset(onset)
223+
n = len(onset)
224+
duration = _check_duration(duration, n)
225+
description = _check_description(description, n)
226+
ch_names = _check_ch_names_annot(ch_names, n)
203227

204228
extras = _validate_extras(extras, len(onset))
205229
return onset, duration, description, ch_names, extras
@@ -408,7 +432,7 @@ def __init__(
408432
f"' '. Got: {orig_time}. Defaulting `orig_time` to None.",
409433
RuntimeWarning,
410434
)
411-
self.onset, self.duration, self.description, self.ch_names, self._extras = (
435+
self._onset, self._duration, self._description, self._ch_names, self._extras = (
412436
_check_o_d_s_c_e(onset, duration, description, ch_names, extras)
413437
)
414438
self._sort() # ensure we're sorted
@@ -418,6 +442,96 @@ def orig_time(self):
418442
"""The time base of the Annotations."""
419443
return self._orig_time
420444

445+
@property
446+
def onset(self):
447+
"""Onset of each annotation (in seconds).
448+
449+
Returns
450+
-------
451+
onset : array of shape (n_annotations,)
452+
The onset of each annotation in seconds from the start of
453+
the recording.
454+
455+
See Also
456+
--------
457+
:attr:`~mne.Annotations.duration`
458+
:attr:`~mne.Annotations.description`
459+
"""
460+
return self._onset
461+
462+
@onset.setter
463+
def onset(self, onset):
464+
onset = _check_onset(onset, n=len(self._onset))
465+
self._onset = onset
466+
467+
@property
468+
def duration(self):
469+
"""Duration of each annotation (in seconds).
470+
471+
Returns
472+
-------
473+
duration : array of shape (n_annotations,)
474+
The duration of each annotation in seconds.
475+
476+
See Also
477+
--------
478+
:attr:`~mne.Annotations.onset`
479+
:attr:`~mne.Annotations.description`
480+
"""
481+
return self._duration
482+
483+
@duration.setter
484+
def duration(self, duration):
485+
n = len(self._duration)
486+
duration = _check_duration(duration, n)
487+
self._duration = duration
488+
489+
@property
490+
def description(self):
491+
"""Description of each annotation.
492+
493+
Returns
494+
-------
495+
description : array of shape (n_annotations,)
496+
A string description for each annotation (e.g., event
497+
label or condition name).
498+
499+
See Also
500+
--------
501+
:attr:`~mne.Annotations.onset`
502+
:attr:`~mne.Annotations.duration`
503+
"""
504+
return self._description
505+
506+
@description.setter
507+
def description(self, description):
508+
n = len(self._description)
509+
description = _check_description(description, n)
510+
self._description = description
511+
512+
@property
513+
def ch_names(self):
514+
"""Channel names associated with each annotation.
515+
516+
Returns
517+
-------
518+
ch_names : list of tuple
519+
Channel names associated with each annotation.
520+
521+
See Also
522+
--------
523+
:attr:`~mne.Annotations.onset`
524+
:attr:`~mne.Annotations.duration`
525+
:attr:`~mne.Annotations.description`
526+
"""
527+
return self._ch_names
528+
529+
@ch_names.setter
530+
def ch_names(self, ch_names):
531+
n = len(self._ch_names)
532+
ch_names = _check_ch_names_annot(ch_names, n)
533+
self._ch_names = ch_names
534+
421535
@property
422536
def extras(self):
423537
"""The extras of the Annotations.
@@ -573,11 +687,15 @@ def append(self, onset, duration, description, ch_names=None, *, extras=None):
573687
onset, duration, description, ch_names, extras = _check_o_d_s_c_e(
574688
onset, duration, description, ch_names, extras
575689
)
576-
self.onset = np.append(self.onset, onset)
577-
self.duration = np.append(self.duration, duration)
578-
self.description = np.append(self.description, description)
579-
self.ch_names = np.append(self.ch_names, ch_names)
580-
self.extras.extend(extras)
690+
# Write directly to private attributes to avoid triggering the public
691+
# setter validation, which would raise an error due to temporary length
692+
# mismatches while fields are being extended one at a time.
693+
# The data is already validated by _check_o_d_s_c_e above.
694+
self._onset = np.append(self._onset, onset)
695+
self._duration = np.append(self._duration, duration)
696+
self._description = np.append(self._description, description)
697+
self._ch_names = np.append(self._ch_names, ch_names)
698+
self._extras.extend(extras)
581699
self._sort()
582700
return self
583701

@@ -600,10 +718,10 @@ def delete(self, idx):
600718
Index of the annotation to remove. Can be array-like to
601719
remove multiple indices.
602720
"""
603-
self.onset = np.delete(self.onset, idx)
604-
self.duration = np.delete(self.duration, idx)
605-
self.description = np.delete(self.description, idx)
606-
self.ch_names = np.delete(self.ch_names, idx)
721+
self._onset = np.delete(self._onset, idx)
722+
self._duration = np.delete(self._duration, idx)
723+
self._description = np.delete(self._description, idx)
724+
self._ch_names = np.delete(self._ch_names, idx)
607725
if isinstance(idx, int_like):
608726
del self.extras[idx]
609727
elif len(idx) > 0:
@@ -740,11 +858,11 @@ def _sort(self):
740858
# the onset-then-duration hierarchy
741859
vals = sorted(zip(self.onset, self.duration, range(len(self))))
742860
order = list(list(zip(*vals))[-1]) if len(vals) else []
743-
self.onset = self.onset[order]
744-
self.duration = self.duration[order]
745-
self.description = self.description[order]
746-
self.ch_names = self.ch_names[order]
747-
self.extras = [self.extras[i] for i in order]
861+
self._onset = self._onset[order]
862+
self._duration = self._duration[order]
863+
self._description = self._description[order]
864+
self._ch_names = self._ch_names[order]
865+
self._extras = [self._extras[i] for i in order]
748866
return order
749867

750868
def _get_crop_lims(self, tmin, tmax, use_orig_time):
@@ -848,12 +966,12 @@ def crop(
848966
ch_names.append(ch)
849967
extras.append(extra)
850968
logger.debug(f"Cropping complete (kept {len(onsets)})")
851-
self.onset = np.array(onsets, float)
852-
self.duration = np.array(durations, float)
853-
assert (self.duration >= 0).all()
854-
self.description = np.array(descriptions, dtype=str)
855-
self.ch_names = _ndarray_ch_names(ch_names)
856-
self.extras = extras
969+
self._onset = np.array(onsets, float)
970+
self._duration = np.array(durations, float)
971+
assert (self._duration >= 0).all()
972+
self._description = np.array(descriptions, dtype=str)
973+
self._ch_names = _ndarray_ch_names(ch_names)
974+
self._extras = extras
857975

858976
if emit_warning:
859977
omitted = np.array(out_of_bounds).sum()
@@ -1168,11 +1286,15 @@ def __getstate__(self):
11681286
def __setstate__(self, state):
11691287
"""Unpack from serialized format."""
11701288
self._orig_time = state["_orig_time"]
1171-
self.onset = state["onset"]
1172-
self.duration = state["duration"]
1173-
self.description = state["description"]
1174-
self.ch_names = state["ch_names"]
1175-
self.extras = state.get("_extras", [None] * len(self.onset))
1289+
self._onset, self._duration, self._description, self._ch_names, self._extras = (
1290+
_check_o_d_s_c_e(
1291+
state["onset"],
1292+
state["duration"],
1293+
state["description"],
1294+
state["ch_names"],
1295+
state.get("_extras", None),
1296+
)
1297+
)
11761298
self._hed_version = state["_hed_version"]
11771299
self.hed_string = _HEDStrings(
11781300
state["hed_string"], hed_version=self._hed_version
@@ -1211,6 +1333,7 @@ def append(
12111333
onset, duration, description, ch_names, extras
12121334
)
12131335
hed_string = self._check_hed_strings(hed_string, len(onset))
1336+
12141337
hed_objs = [
12151338
self.hed_string._validate_hed_string(v, self.hed_string._schema)
12161339
for v in hed_string
@@ -1229,7 +1352,6 @@ def append(
12291352
def __iadd__(self, other):
12301353
"""Add (concatenate) two HEDAnnotations objects in-place."""
12311354
if not isinstance(other, type(self)):
1232-
# Convert self to plain Annotations, preserving HED in extras
12331355
extras = _hed_extras_from_hed_annotations(self)
12341356
result = Annotations(
12351357
onset=self.onset,

mne/tests/test_annotations.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,41 @@ def test_annotation_duration_setting():
17641764
a.set_durations({"aaa", 2.2})
17651765

17661766

1767+
def test_setter_validation():
1768+
"""Test that onset/duration/description/ch_names setters validate length."""
1769+
annots = Annotations(onset=[1, 3, 2, 4], duration=0, description="foo")
1770+
1771+
# onset mismatch should raise
1772+
with pytest.raises(ValueError, match="Length of onset"):
1773+
annots.onset = annots.onset[:2]
1774+
1775+
# duration mismatch should raise
1776+
with pytest.raises(ValueError, match="Length of duration"):
1777+
annots.duration = annots.duration[:2]
1778+
1779+
# description mismatch should raise
1780+
with pytest.raises(ValueError, match="Length of description"):
1781+
annots.description = annots.description[:2]
1782+
1783+
# scalar duration should broadcast without error
1784+
annots.duration = 1.0
1785+
assert len(annots.duration) == 4
1786+
assert all(annots.duration == 1.0)
1787+
1788+
# scalar description should broadcast without error
1789+
annots.description = "bad"
1790+
assert len(annots.description) == 4
1791+
assert all(annots.description == "bad")
1792+
1793+
# ch_names mismatch should raise
1794+
with pytest.raises(ValueError, match="Length of ch_names"):
1795+
annots.ch_names = [(), ()]
1796+
1797+
# valid ch_names assignment (correct length) should succeed
1798+
annots.ch_names = [("MEG 0111",), (), (), ()]
1799+
assert annots.ch_names[0] == ("MEG 0111",)
1800+
1801+
17671802
@pytest.mark.parametrize("meas_date", (None, 1))
17681803
@pytest.mark.parametrize("set_meas_date", ("before", "after"))
17691804
@pytest.mark.parametrize("first_samp", (0, 100, 3000))

0 commit comments

Comments
 (0)