From 8f0c0183a38be43a187c1941213d9c6fe3043c43 Mon Sep 17 00:00:00 2001 From: Kay Robbins <1189050+VisLab@users.noreply.github.com> Date: Mon, 13 Jan 2025 07:28:10 -0600 Subject: [PATCH 1/8] Added a TODO to start implementation of HED support in annotations --- mne/annotations.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/annotations.py b/mne/annotations.py index 629ee7b20cb..1c9ad85b1c9 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -757,6 +757,7 @@ def rename(self, mapping, verbose=None): self.description = np.array([str(mapping.get(d, d)) for d in self.description]) return self +# TODO: Add support for HED annotations for use in epoching. class EpochAnnotationsMixin: """Mixin class for Annotations in Epochs.""" From 6f6ccdceb240438906a643ab7cd51de8b5ebcfe4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:33:33 +0000 Subject: [PATCH 2/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/annotations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mne/annotations.py b/mne/annotations.py index 1c9ad85b1c9..694836d8188 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -757,8 +757,10 @@ def rename(self, mapping, verbose=None): self.description = np.array([str(mapping.get(d, d)) for d in self.description]) return self + # TODO: Add support for HED annotations for use in epoching. + class EpochAnnotationsMixin: """Mixin class for Annotations in Epochs.""" From c31e837a7934b13267561e61e62ae4b403feee90 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 13 Jan 2025 16:42:30 -0600 Subject: [PATCH 3/8] add sketch of HEDAnnotations [ci skip] --- mne/__init__.pyi | 2 + mne/annotations.py | 139 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/mne/__init__.pyi b/mne/__init__.pyi index d50b5209346..6560854402e 100644 --- a/mne/__init__.pyi +++ b/mne/__init__.pyi @@ -11,6 +11,7 @@ __all__ = [ "Evoked", "EvokedArray", "Forward", + "HEDAnnotations", "Info", "Label", "MixedSourceEstimate", @@ -260,6 +261,7 @@ from ._freesurfer import ( ) from .annotations import ( Annotations, + HEDAnnotations, annotations_from_events, count_annotations, events_from_annotations, diff --git a/mne/annotations.py b/mne/annotations.py index 694836d8188..a784c3ae143 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -52,6 +52,7 @@ verbose, warn, ) +from .utils.check import _soft_import # For testing windows_like_datetime, we monkeypatch "datetime" in this module. # Keep the true datetime object around for _validate_type use. @@ -151,6 +152,7 @@ class Annotations: -------- mne.annotations_from_events mne.events_from_annotations + mne.HEDAnnotations Notes ----- @@ -288,7 +290,7 @@ def orig_time(self): def __eq__(self, other): """Compare to another Annotations instance.""" - if not isinstance(other, Annotations): + if not isinstance(other, type(self)): return False return ( np.array_equal(self.onset, other.onset) @@ -567,6 +569,8 @@ def _sort(self): self.duration = self.duration[order] self.description = self.description[order] self.ch_names = self.ch_names[order] + if hasattr(self, "hed_tags"): + self.hed_tags = self.hed_tags[order] @verbose def crop( @@ -758,7 +762,138 @@ def rename(self, mapping, verbose=None): return self -# TODO: Add support for HED annotations for use in epoching. +class HEDAnnotations(Annotations): + """Annotations object for annotating segments of raw data with HED tags. + + Parameters + ---------- + onset : array of float, shape (n_annotations,) + The starting time of annotations in seconds after ``orig_time``. + duration : array of float, shape (n_annotations,) | float + Durations of the annotations in seconds. If a float, all the + annotations are given the same duration. + description : array of str, shape (n_annotations,) | str + Array of strings containing description for each annotation. If a + string, all the annotations are given the same description. To reject + epochs, use description starting with keyword 'bad'. See example above. + hed_tags : array of str, shape (n_annotations,) | str + Array of strings containing a HED tag for each annotation. If a single string + is provided, all annotations are given the same HED tag. + hed_version : str + The HED schema version against which to validate the HED tags. + orig_time : float | str | datetime | tuple of int | None + A POSIX Timestamp, datetime or a tuple containing the timestamp as the + first element and microseconds as the second element. Determines the + starting time of annotation acquisition. If None (default), + starting time is determined from beginning of raw data acquisition. + In general, ``raw.info['meas_date']`` (or None) can be used for syncing + the annotations with raw data if their acquisition is started at the + same time. If it is a string, it should conform to the ISO8601 format. + More precisely to this '%%Y-%%m-%%d %%H:%%M:%%S.%%f' particular case of + the ISO8601 format where the delimiter between date and time is ' '. + %(ch_names_annot)s + + See Also + -------- + mne.Annotations + + Notes + ----- + + .. versionadded:: 1.10 + """ + + def __init__( + self, + onset, + duration, + description, + hed_tags, + hed_version="latest", # TODO @VisLab what is a sensible default here? + orig_time=None, + ch_names=None, + ): + hed = _soft_import("hed", "validation of HED tags in annotations") # noqa + # TODO is some sort of initialization of the HED cache directory necessary? + super().__init__( + onset=onset, + duration=duration, + description=description, + orig_time=orig_time, + ch_names=ch_names, + ) + # TODO validate the HED version the user claims to be using. + self.hed_version = hed_version + self._update_hed_tags(hed_tags=hed_tags) + + def _update_hed_tags(self, hed_tags): + if len(hed_tags) != len(self): + raise ValueError( + f"Number of HED tags ({len(hed_tags)}) must match the number of " + f"annotations ({len(self)})." + ) + # TODO insert validation of HED tags here + self.hed_tags = hed_tags + + def __eq__(self, other): + """Compare to another HEDAnnotations instance.""" + return ( + super().__eq__(self, other) + and np.array_equal(self.hed_tags, other.hed_tags) + and self.hed_version == other.hed_version + ) + + def __repr__(self): + """Show a textual summary of the object.""" + counter = Counter(self.hed_tags) + kinds = ", ".join(["{} ({})".format(*k) for k in sorted(counter.items())]) + kinds = (": " if len(kinds) > 0 else "") + kinds + ch_specific = ", channel-specific" if self._any_ch_names() else "" + s = ( + f"HEDAnnotations | {len(self.onset)} segment" + f"{_pl(len(self.onset))}{ch_specific}{kinds}" + ) + return "<" + shorten(s, width=77, placeholder=" ...") + ">" + + def __getitem__(self, key, *, with_ch_names=None): + """Propagate indexing and slicing to the underlying numpy structure.""" + result = super().__getitem__(self, key, with_ch_names=with_ch_names) + if isinstance(result, OrderedDict): + result["hed_tags"] = self.hed_tags[key] + else: + key = list(key) if isinstance(key, tuple) else key + hed_tags = self.hed_tags[key] + return HEDAnnotations( + result.onset, + result.duration, + result.description, + hed_tags, + hed_version=self.hed_version, + orig_time=self.orig_time, + ch_names=result.ch_names, + ) + + def append(self, onset, duration, description, ch_names=None): + """TODO.""" + pass + + def count(self): + """TODO. Unlike Annotations.count, keys should be HED tags not descriptions.""" + pass + + def crop( + self, tmin=None, tmax=None, emit_warning=False, use_orig_time=True, verbose=None + ): + """TODO.""" + pass + + def delete(self, idx): + """TODO.""" + pass + + def to_data_frame(self, time_format="datetime"): + """TODO.""" + pass class EpochAnnotationsMixin: From b3183d353c32606bd96e54885a7f54d323183a1b Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 31 Jan 2025 14:15:59 -0600 Subject: [PATCH 4/8] rename hed_tags -> hed_strings --- mne/annotations.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/mne/annotations.py b/mne/annotations.py index a784c3ae143..d74f37882cf 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -569,8 +569,8 @@ def _sort(self): self.duration = self.duration[order] self.description = self.description[order] self.ch_names = self.ch_names[order] - if hasattr(self, "hed_tags"): - self.hed_tags = self.hed_tags[order] + if hasattr(self, "hed_strings"): + self.hed_strings = self.hed_strings[order] @verbose def crop( @@ -776,11 +776,12 @@ class HEDAnnotations(Annotations): Array of strings containing description for each annotation. If a string, all the annotations are given the same description. To reject epochs, use description starting with keyword 'bad'. See example above. - hed_tags : array of str, shape (n_annotations,) | str - Array of strings containing a HED tag for each annotation. If a single string - is provided, all annotations are given the same HED tag. + hed_strings : array of str, shape (n_annotations,) | str + Sequence of strings containing a HED tag (or comma-separated list of HED tags) + for each annotation. If a single string is provided, all annotations are + assigned the same HED string. hed_version : str - The HED schema version against which to validate the HED tags. + The HED schema version against which to validate the HED strings. orig_time : float | str | datetime | tuple of int | None A POSIX Timestamp, datetime or a tuple containing the timestamp as the first element and microseconds as the second element. Determines the @@ -808,7 +809,7 @@ def __init__( onset, duration, description, - hed_tags, + hed_strings, hed_version="latest", # TODO @VisLab what is a sensible default here? orig_time=None, ch_names=None, @@ -824,28 +825,28 @@ def __init__( ) # TODO validate the HED version the user claims to be using. self.hed_version = hed_version - self._update_hed_tags(hed_tags=hed_tags) + self._update_hed_strings(hed_strings=hed_strings) - def _update_hed_tags(self, hed_tags): - if len(hed_tags) != len(self): + def _update_hed_strings(self, hed_strings): + if len(hed_strings) != len(self): raise ValueError( - f"Number of HED tags ({len(hed_tags)}) must match the number of " + f"Number of HED strings ({len(hed_strings)}) must match the number of " f"annotations ({len(self)})." ) - # TODO insert validation of HED tags here - self.hed_tags = hed_tags + # TODO insert validation of HED strings here + self.hed_strings = hed_strings def __eq__(self, other): """Compare to another HEDAnnotations instance.""" return ( super().__eq__(self, other) - and np.array_equal(self.hed_tags, other.hed_tags) + and np.array_equal(self.hed_strings, other.hed_strings) and self.hed_version == other.hed_version ) def __repr__(self): """Show a textual summary of the object.""" - counter = Counter(self.hed_tags) + counter = Counter(self.hed_strings) kinds = ", ".join(["{} ({})".format(*k) for k in sorted(counter.items())]) kinds = (": " if len(kinds) > 0 else "") + kinds ch_specific = ", channel-specific" if self._any_ch_names() else "" @@ -859,15 +860,15 @@ def __getitem__(self, key, *, with_ch_names=None): """Propagate indexing and slicing to the underlying numpy structure.""" result = super().__getitem__(self, key, with_ch_names=with_ch_names) if isinstance(result, OrderedDict): - result["hed_tags"] = self.hed_tags[key] + result["hed_strings"] = self.hed_strings[key] else: key = list(key) if isinstance(key, tuple) else key - hed_tags = self.hed_tags[key] + hed_strings = self.hed_strings[key] return HEDAnnotations( result.onset, result.duration, result.description, - hed_tags, + hed_strings, hed_version=self.hed_version, orig_time=self.orig_time, ch_names=result.ch_names, From 4065433c2bfb1066488b8bb0a939170cbf7df479 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 31 Jan 2025 14:16:22 -0600 Subject: [PATCH 5/8] remove unnecessary TODOs --- mne/annotations.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mne/annotations.py b/mne/annotations.py index d74f37882cf..79a47e3d20b 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -815,7 +815,6 @@ def __init__( ch_names=None, ): hed = _soft_import("hed", "validation of HED tags in annotations") # noqa - # TODO is some sort of initialization of the HED cache directory necessary? super().__init__( onset=onset, duration=duration, @@ -823,7 +822,6 @@ def __init__( orig_time=orig_time, ch_names=ch_names, ) - # TODO validate the HED version the user claims to be using. self.hed_version = hed_version self._update_hed_strings(hed_strings=hed_strings) From 52400411f2634092e46ce15d2ccafe11ef0bdbe0 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 31 Jan 2025 14:49:44 -0600 Subject: [PATCH 6/8] add basic validation --- mne/annotations.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/mne/annotations.py b/mne/annotations.py index 79a47e3d20b..e798f60243c 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -810,11 +810,12 @@ def __init__( duration, description, hed_strings, - hed_version="latest", # TODO @VisLab what is a sensible default here? + hed_version="8.3.0", # TODO @VisLab what is a sensible default here? orig_time=None, ch_names=None, ): - hed = _soft_import("hed", "validation of HED tags in annotations") # noqa + self.hed = _soft_import("hed", "validation of HED tags in annotations") + super().__init__( onset=onset, duration=duration, @@ -826,14 +827,37 @@ def __init__( self._update_hed_strings(hed_strings=hed_strings) def _update_hed_strings(self, hed_strings): + # NB: must import; calling self.hed.validator.HedValidator doesn't work + from hed.validator import HedValidator + if len(hed_strings) != len(self): raise ValueError( f"Number of HED strings ({len(hed_strings)}) must match the number of " f"annotations ({len(self)})." ) - # TODO insert validation of HED strings here + # validation of HED strings + schema = self.hed.load_schema_version(self.hed_version) + validator = HedValidator(schema) + error_handler = self.hed.errors.ErrorHandler(check_for_warnings=False) + error_strs = [ + self._validate_one_hed_string(hs, schema, validator, error_handler) + for hs in hed_strings + ] + if any(map(len, error_strs)): + raise ValueError( + "Some HED strings in your annotations failed to validate:\n" + + "\n - ".join(error_strs) + ) self.hed_strings = hed_strings + def _validate_one_hed_string(self, hed_string, schema, validator, error_handler): + """Validate a user-provided HED string.""" + foo = self.hed.HedString(hed_string, schema) + issues = validator.validate( + foo, allow_placeholders=False, error_handler=error_handler + ) + return self.hed.get_printable_issue_string(issues) + def __eq__(self, other): """Compare to another HEDAnnotations instance.""" return ( From 40311f0557268af59116b84b2ee66eec70365e39 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 31 Jan 2025 14:52:28 -0600 Subject: [PATCH 7/8] fix err message indentation --- mne/annotations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/annotations.py b/mne/annotations.py index e798f60243c..ac2d527826e 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -845,7 +845,7 @@ def _update_hed_strings(self, hed_strings): ] if any(map(len, error_strs)): raise ValueError( - "Some HED strings in your annotations failed to validate:\n" + "Some HED strings in your annotations failed to validate:\n - " + "\n - ".join(error_strs) ) self.hed_strings = hed_strings From 1485356e5c87a8ea2c53b843989544f0076f48d7 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 31 Jan 2025 14:53:26 -0600 Subject: [PATCH 8/8] don't call it foo --- mne/annotations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/annotations.py b/mne/annotations.py index ac2d527826e..09d907637a7 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -852,9 +852,9 @@ def _update_hed_strings(self, hed_strings): def _validate_one_hed_string(self, hed_string, schema, validator, error_handler): """Validate a user-provided HED string.""" - foo = self.hed.HedString(hed_string, schema) + hs = self.hed.HedString(hed_string, schema) issues = validator.validate( - foo, allow_placeholders=False, error_handler=error_handler + hs, allow_placeholders=False, error_handler=error_handler ) return self.hed.get_printable_issue_string(issues)