diff --git a/doc/changes/dev/14086.newfeature.rst b/doc/changes/dev/14086.newfeature.rst new file mode 100644 index 00000000000..2dd4d4e2335 --- /dev/null +++ b/doc/changes/dev/14086.newfeature.rst @@ -0,0 +1 @@ +When reading EGI MFF files with :func:`mne.io.read_raw_egi`, passing ``event_key='cel#'`` now returns compound event codes (e.g. ``stm+_1`` and ``stm+_2``) so that trials from different conditions can be distinguished via ``raw.event_id``, by `Pragnya Khandelwal`_ and `Scott Huberty`_. diff --git a/mne/io/egi/egi.py b/mne/io/egi/egi.py index 21bfc318a33..eb595b9c39d 100644 --- a/mne/io/egi/egi.py +++ b/mne/io/egi/egi.py @@ -102,6 +102,7 @@ def read_raw_egi( channel_naming: str = "E%d", *, events_as_annotations: bool = True, + event_key: str | None = None, verbose: bool | str | int | None = None, ) -> "RawEGI": """Read EGI simple binary as raw object. @@ -142,6 +143,15 @@ def read_raw_egi( The default will change from False to True in version 1.9. .. versionadded:: 1.8.0 + event_key : str | None + The MFF event key whose value is appended to annotation descriptions and extras + when ``events_as_annotations=True``, and to stim channel names when + ``events_as_annotations=False``. For example, ``event_key='cel#'`` will convert + an MFF event code ``'stim'`` with ``cel#=1`` to ``'stim_1'``. These will also + appear as ``'event_key_cel#': 1`` in annotations.extras + Only supported for MFF files. + + .. versionadded:: 1.11.0 %(verbose)s Returns @@ -156,22 +166,31 @@ def read_raw_egi( Notes ----- - When ``events_from_annotations=True``, event codes on stimulus channels like + When ``events_as_annotations=True``, event codes on stimulus channels like ``DIN1`` are stored as annotations with the ``description`` set to the stimulus channel name. - When ``events_from_annotations=False`` and events are present on the included - stimulus channels, a new stim channel ``STI014`` will be synthesized from the + When ``events_as_annotations=False`` and events are present on the included + stimulus channels, a new stim channel ``STI 014`` will be synthesized from the events. It will contain 1-sample pulses where the Netstation file had event timestamps. A ``raw.event_id`` dictionary is added to the raw object that will have arbitrary sequential integer IDs for the events. This will fail if any timestamps are duplicated. The ``event_id`` will also not survive a save/load roundtrip. For these reasons, it is recommended to use ``events_as_annotations=True``. + + MFF event track XML files can store additional metadata in a + ```` child element of each ```` element. This corresponds to + the ECI Event Data Stream "Key List" described in the + `EGI Amp Server Pro SDK User Guide + `__. + For example, E-Prime driven experiments sometimes store the experimental condition + in a ``'cel#'`` event key. """ _validate_type(input_fname, "path-like", "input_fname") input_fname = str(input_fname) _validate_type(events_as_annotations, bool, "events_as_annotations") + _validate_type(event_key, (str, None), "event_key") if input_fname.rstrip("/\\").endswith(".mff"): # allows .mff or .mff/ return _read_raw_egi_mff( @@ -183,8 +202,10 @@ def read_raw_egi( preload, channel_naming, events_as_annotations=events_as_annotations, + event_key=event_key, verbose=verbose, ) + return RawEGI( input_fname, eog, diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index f875d021308..c21c32b203d 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -369,6 +369,7 @@ def _read_raw_egi_mff( channel_naming="E%d", *, events_as_annotations=True, + event_key=None, verbose=None, ): """Read EGI mff binary as raw object.""" @@ -381,6 +382,7 @@ def _read_raw_egi_mff( preload, channel_naming, events_as_annotations=events_as_annotations, + event_key=event_key, verbose=verbose, ) @@ -402,6 +404,7 @@ def __init__( channel_naming="E%d", *, events_as_annotations=True, + event_key=None, verbose=None, ): """Init the RawMff class.""" @@ -422,7 +425,9 @@ def __init__( misc = np.where(np.array(egi_info["chan_type"]) != "eeg")[0].tolist() logger.info(" Reading events ...") - egi_events, egi_info, mff_events = _read_events(input_fname, egi_info) + egi_events, egi_info, mff_events = _read_events( + input_fname, egi_info, event_key=event_key + ) cals = np.array([CAL_SCALES[t] for t in egi_info["chan_unit"]]) logger.info(" Assembling measurement info ...") event_codes = egi_info["event_codes"] diff --git a/mne/io/egi/events.py b/mne/io/egi/events.py index 74dc898b56e..5b4f01ebc95 100644 --- a/mne/io/egi/events.py +++ b/mne/io/egi/events.py @@ -10,7 +10,7 @@ from ...utils import _soft_import, _validate_type, logger, warn -def _read_events(input_fname, info): +def _read_events(input_fname, info, *, event_key=None): """Read events for the record. Parameters @@ -22,7 +22,7 @@ def _read_events(input_fname, info): """ n_samples = info["last_samps"][-1] mff_events, event_codes = _read_mff_events( - input_fname, info["sfreq"], info["meas_dt_local"] + input_fname, info["sfreq"], info["meas_dt_local"], event_key=event_key ) info["n_events"] = len(event_codes) info["event_codes"] = event_codes @@ -36,7 +36,7 @@ def _read_events(input_fname, info): return events, info, mff_events -def _read_mff_events(filename, sfreq, start_time): +def _read_mff_events(filename, sfreq, start_time, *, event_key=None): """Extract the events with mffpy.""" mffpy = _soft_import("mffpy", purpose="reading EGI MFF data", min_version="0.11") from mffpy.xml_files import XML @@ -69,6 +69,10 @@ def _read_mff_events(filename, sfreq, start_time): for track in tracks: for event in track.events: code_str = event["code"] + keys = event.get("keys", {}) + key_value = keys.get(event_key) if event_key is not None else None + if key_value is not None: + code_str = f"{code_str}_{key_value}" if code_str not in code: code.append(code_str) @@ -83,6 +87,14 @@ def _read_mff_events(filename, sfreq, start_time): ) else: extras = {} + extras.update( + { + f"event_key_{key}": value.item() + if isinstance(value, np.generic) + else value + for key, value in keys.items() + } + ) markers.append( { diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py index 38522b94e24..2880518c356 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -598,3 +598,22 @@ def test_egi_mff_bad_xml(tmp_path): raw = read_raw_egi(mff_fname) # little check that the bad XML doesn't affect the parsing of other xml files assert "DIN1" in raw.annotations.description + + +@requires_testing_data +@pytest.mark.parametrize( + "fname, expected", + [pytest.param(egi_pause_fname, "AM40_3", id="paused")], +) +def test_read_event_keys(fname, expected): + """Test event metadata extraction from```` child elements.""" + with pytest.warns(RuntimeWarning, match="Acquisition skips detected"): + raw = _test_raw_reader( + read_raw_egi, + input_fname=fname, + test_scaling=False, + test_rank="less", + events_as_annotations=True, + event_key="cel#", + ) + assert expected in raw.annotations.description