@@ -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 ,
0 commit comments