Skip to content

Commit b3524b6

Browse files
Support mixed hed annotations, concat and solve version registration (#13736)
1 parent 2c008ad commit b3524b6

6 files changed

Lines changed: 134 additions & 4 deletions

File tree

doc/changes/dev/13736.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support mixed-type concatenation of :class:`mne.Annotations` and :class:`mne.HEDAnnotations`, preserving HED strings in ``extras["HED"]``, by `Bruno Aristimunha`_.

mne/annotations.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,12 +489,15 @@ def __iadd__(self, other):
489489
"orig_time should be the same to add/concatenate 2 annotations (got "
490490
f"{self.orig_time} != {other.orig_time})"
491491
)
492+
extras = other.extras
493+
if hasattr(other, "hed_string"):
494+
extras = _hed_extras_from_hed_annotations(other)
492495
return self.append(
493496
other.onset,
494497
other.duration,
495498
other.description,
496499
other.ch_names,
497-
extras=other.extras,
500+
extras=extras,
498501
)
499502

500503
def __iter__(self):
@@ -982,6 +985,11 @@ def append(self, item):
982985
self._objs.append(hs)
983986

984987

988+
def _hed_extras_from_hed_annotations(annot):
989+
"""Convert HEDAnnotations hed_string data into extras dicts with "HED" key."""
990+
return [{**d, "HED": str(hs)} for d, hs in zip(annot.extras, annot.hed_string)]
991+
992+
985993
@fill_doc
986994
class HEDAnnotations(Annotations):
987995
"""Annotations object for annotating segments of raw data with HED tags.
@@ -1024,6 +1032,16 @@ class HEDAnnotations(Annotations):
10241032
10251033
Notes
10261034
-----
1035+
When concatenating annotations using the ``+`` operator or ``+=``:
1036+
1037+
- ``HEDAnnotations + HEDAnnotations`` returns a
1038+
:class:`~mne.HEDAnnotations`.
1039+
- ``HEDAnnotations + Annotations`` returns a plain
1040+
:class:`~mne.Annotations`, with HED strings preserved in
1041+
``extras["HED"]``.
1042+
- ``Annotations + HEDAnnotations`` returns a plain
1043+
:class:`~mne.Annotations`, with HED strings preserved in
1044+
``extras["HED"]``.
10271045
10281046
.. versionadded:: 1.12
10291047
"""
@@ -1211,9 +1229,18 @@ def append(
12111229
def __iadd__(self, other):
12121230
"""Add (concatenate) two HEDAnnotations objects in-place."""
12131231
if not isinstance(other, type(self)):
1214-
raise TypeError(
1215-
f"Cannot concatenate {type(self).__name__} and {type(other).__name__}."
1232+
# Convert self to plain Annotations, preserving HED in extras
1233+
extras = _hed_extras_from_hed_annotations(self)
1234+
result = Annotations(
1235+
onset=self.onset,
1236+
duration=self.duration,
1237+
description=self.description,
1238+
orig_time=self.orig_time,
1239+
ch_names=self.ch_names,
1240+
extras=extras,
12161241
)
1242+
result += other
1243+
return result
12171244
if len(self) == 0:
12181245
self._orig_time = other.orig_time
12191246
if self.orig_time != other.orig_time:

mne/tests/test_annotations.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,103 @@ def test_hed_annotations():
21302130
assert list(empty_ann.hed_string) == []
21312131

21322132

2133+
def test_hed_annotations_mixed_concatenation():
2134+
"""Test concatenation of HEDAnnotations with regular Annotations."""
2135+
pytest.importorskip("hed")
2136+
tone = (
2137+
"Sensory-event, Experimental-stimulus, Auditory-presentation, "
2138+
"(Tone, Frequency/550 Hz)"
2139+
)
2140+
press = "Agent-action, (Experiment-participant, (Press, Mouse-button))"
2141+
2142+
hed = HEDAnnotations(
2143+
onset=[0, 1],
2144+
duration=[0.1, 0.2],
2145+
description=["tone", "press"],
2146+
hed_string=[tone, press],
2147+
extras=[{"run": 1}, {"run": 2}],
2148+
)
2149+
reg = Annotations(
2150+
onset=[2, 3],
2151+
duration=[0.3, 0.4],
2152+
description=["plain1", "plain2"],
2153+
extras=[{"run": 3}, {}],
2154+
)
2155+
2156+
# --- Annotations += HEDAnnotations ---
2157+
combined = reg.copy()
2158+
combined += hed
2159+
assert isinstance(combined, Annotations)
2160+
assert not isinstance(combined, HEDAnnotations)
2161+
assert len(combined) == 4
2162+
# sorted by onset: hed(0,1) then reg(2,3)
2163+
assert combined.extras[0]["HED"] == tone
2164+
assert combined.extras[1]["HED"] == press
2165+
assert "HED" not in combined.extras[2]
2166+
assert "HED" not in combined.extras[3]
2167+
# pre-existing extras preserved
2168+
assert combined.extras[0]["run"] == 1
2169+
assert combined.extras[1]["run"] == 2
2170+
assert combined.extras[2]["run"] == 3
2171+
2172+
# --- HEDAnnotations += Annotations ---
2173+
combined2 = hed.copy()
2174+
combined2 += reg
2175+
# result is plain Annotations (rebinding semantics)
2176+
assert isinstance(combined2, Annotations)
2177+
assert not isinstance(combined2, HEDAnnotations)
2178+
assert len(combined2) == 4
2179+
# sorted by onset: hed(0,1) then reg(2,3)
2180+
assert combined2.extras[0]["HED"] == tone
2181+
assert combined2.extras[1]["HED"] == press
2182+
assert "HED" not in combined2.extras[2]
2183+
assert "HED" not in combined2.extras[3]
2184+
# pre-existing extras preserved
2185+
assert combined2.extras[0]["run"] == 1
2186+
assert combined2.extras[2]["run"] == 3
2187+
2188+
# --- non-mutating + operators ---
2189+
hed_orig = hed.copy()
2190+
reg_orig = reg.copy()
2191+
2192+
out1 = reg + hed
2193+
assert isinstance(out1, Annotations)
2194+
assert not isinstance(out1, HEDAnnotations)
2195+
assert len(out1) == 4
2196+
assert out1.extras[0]["HED"] == tone
2197+
assert out1.extras[1]["HED"] == press
2198+
2199+
out2 = hed + reg
2200+
assert isinstance(out2, Annotations)
2201+
assert not isinstance(out2, HEDAnnotations)
2202+
assert len(out2) == 4
2203+
assert out2.extras[0]["HED"] == tone
2204+
assert out2.extras[1]["HED"] == press
2205+
2206+
# originals are not mutated
2207+
assert hed == hed_orig
2208+
assert reg == reg_orig
2209+
2210+
# --- ch_names are preserved ---
2211+
hed_ch = HEDAnnotations(
2212+
onset=[0],
2213+
duration=[0.1],
2214+
description=["x"],
2215+
hed_string=[tone],
2216+
ch_names=[["EEG 001"]],
2217+
)
2218+
reg_ch = Annotations(
2219+
onset=[1],
2220+
duration=[0.1],
2221+
description=["y"],
2222+
ch_names=[["EEG 002"]],
2223+
)
2224+
out3 = reg_ch + hed_ch
2225+
# sorted by onset: hed_ch(0) then reg_ch(1)
2226+
assert out3[0]["ch_names"] == ("EEG 001",)
2227+
assert out3[1]["ch_names"] == ("EEG 002",)
2228+
2229+
21332230
def test_hed_annotations_to_data_frame():
21342231
"""Test HEDAnnotations.to_data_frame()."""
21352232
pytest.importorskip("hed")

mne/utils/check.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ def _soft_import(name, purpose, strict=True, *, min_version=None):
407407
mne_connectivity="mne-connectivity",
408408
mne_gui_addons="mne-gui-addons",
409409
pyvista="pyvistaqt",
410+
hed="hedtools",
410411
).get(name, name)
411412

412413
got_version = None

mne/utils/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,7 @@ def sys_info(
848848
use_mod_names += (
849849
"# Testing",
850850
"pytest",
851+
"hedtools",
851852
"statsmodels",
852853
"numpydoc",
853854
"jupyter_client",
@@ -877,6 +878,7 @@ def sys_info(
877878
except Exception: # in case someone overrides sys.stdout in an unsafe way
878879
unicode = False
879880
mne_version_good = True
881+
import_names = dict(hedtools="hed")
880882
for mi, mod_name in enumerate(use_mod_names):
881883
# upcoming break
882884
if mod_name == "": # break
@@ -897,7 +899,8 @@ def sys_info(
897899
if last:
898900
pre = "└"
899901
try:
900-
mod = import_module(mod_name.replace("-", "_"))
902+
import_name = import_names.get(mod_name, mod_name.replace("-", "_"))
903+
mod = import_module(import_name)
901904
except Exception:
902905
unavailable.append(mod_name)
903906
else:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ test = [
6565
test_extra = [
6666
"edfio >= 0.4.10",
6767
"eeglabio",
68+
"hedtools",
6869
"imageio >= 2.6.1",
6970
"imageio-ffmpeg >= 0.4.1",
7071
"jupyter_client",

0 commit comments

Comments
 (0)