From 25794aac64a24a347dbff89eb9574a5afc9c21f6 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Tue, 21 Jul 2026 21:30:01 +0530 Subject: [PATCH 1/6] [circle skip] [skip azp] --- mne/io/egi/events.py | 3 +++ mne/io/egi/tests/test_egi.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/mne/io/egi/events.py b/mne/io/egi/events.py index 74dc898b56e..3cd46028010 100644 --- a/mne/io/egi/events.py +++ b/mne/io/egi/events.py @@ -69,6 +69,9 @@ def _read_mff_events(filename, sfreq, start_time): for track in tracks: for event in track.events: code_str = event["code"] + cel = event.get("keys", {}).get("cel#") + if cel is not None: + code_str = f"{code_str}_{int(cel)}" if code_str not in code: code.append(code_str) diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py index 38522b94e24..67f204e2f99 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -18,6 +18,7 @@ from mne.datasets.testing import data_path, requires_testing_data from mne.io import read_evokeds_mff, read_raw_egi, read_raw_fif from mne.io.egi.egi import _combine_triggers +from mne.io.egi.events import _read_mff_events from mne.io.tests.test_raw import _test_raw_reader from mne.utils import copytree_rw, object_diff @@ -598,3 +599,40 @@ 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 +def test_egi_mff_cel_condition_codes(): + """Test that cel# compound codes are only generated for repeated trial events. + + EGI records a ``cel#`` key on each event to identify the experimental + condition. When a code appears multiple times per condition (e.g. ``stm+`` + firing on every trial) ``read_raw_egi`` should append the cel# value and + produce codes like ``stm+_1`` / ``stm+_2``. + + However, some files use a once-per-condition-block ``CELL`` marker where + each cel# value (1–5) fires exactly once. Splitting those into separate + channels would create consecutive-sample trigger values that confuse + ``find_events`` and prevent ``STI 014`` from being synthesised. The reader + must leave such codes unsplit. + """ + import mffpy + + for fname in (egi_pause_fname, egi_eprime_pause_fname): + r = mffpy.Reader(str(fname)) + _, codes = _read_mff_events(str(fname), 250.0, r.startdatetime) + assert "CELL" in codes, f"CELL missing in {fname.name}" + assert not any(c.startswith("CELL_") for c in codes), ( + f"CELL should not be split into per-condition channels in " + f"{fname.name}; got {codes}" + ) + + # Also verify end-to-end: STI 014 must be synthesised and CELL must appear + # in raw.event_id (not split) when reading the full file. + for fname in (egi_pause_fname, egi_eprime_pause_fname): + raw = read_raw_egi(fname, events_as_annotations=False) + assert "CELL" in raw.event_id, f"CELL missing from event_id in {fname.name}" + assert not any(k.startswith("CELL_") for k in raw.event_id), ( + f"CELL should not be split in {fname.name}; got {list(raw.event_id)}" + ) + assert "STI 014" in raw.ch_names, f"STI 014 not synthesised in {fname.name}" From 32ad6046ecbe52b72b0d067b29d96d9e216044c4 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Tue, 21 Jul 2026 21:32:52 +0530 Subject: [PATCH 2/6] [circle skip] [skip azp] [ci skip] checkpoint From cc294bb9db10f70ffc507a993b9a3c38ccbc52a7 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 21 Jul 2026 13:25:14 -0700 Subject: [PATCH 3/6] Event Keys --- mne/io/egi/egi.py | 27 +++++++++++++++++--- mne/io/egi/egimff.py | 7 +++++- mne/io/egi/events.py | 21 +++++++++++----- mne/io/egi/tests/test_egi.py | 49 +++++++++++------------------------- 4 files changed, 60 insertions(+), 44 deletions(-) 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 3cd46028010..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,9 +69,10 @@ def _read_mff_events(filename, sfreq, start_time): for track in tracks: for event in track.events: code_str = event["code"] - cel = event.get("keys", {}).get("cel#") - if cel is not None: - code_str = f"{code_str}_{int(cel)}" + 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) @@ -86,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 67f204e2f99..5eda5c5616a 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -600,39 +600,20 @@ def test_egi_mff_bad_xml(tmp_path): # little check that the bad XML doesn't affect the parsing of other xml files assert "DIN1" in raw.annotations.description - @requires_testing_data -def test_egi_mff_cel_condition_codes(): - """Test that cel# compound codes are only generated for repeated trial events. - - EGI records a ``cel#`` key on each event to identify the experimental - condition. When a code appears multiple times per condition (e.g. ``stm+`` - firing on every trial) ``read_raw_egi`` should append the cel# value and - produce codes like ``stm+_1`` / ``stm+_2``. - - However, some files use a once-per-condition-block ``CELL`` marker where - each cel# value (1–5) fires exactly once. Splitting those into separate - channels would create consecutive-sample trigger values that confuse - ``find_events`` and prevent ``STI 014`` from being synthesised. The reader - must leave such codes unsplit. - """ - import mffpy - - for fname in (egi_pause_fname, egi_eprime_pause_fname): - r = mffpy.Reader(str(fname)) - _, codes = _read_mff_events(str(fname), 250.0, r.startdatetime) - assert "CELL" in codes, f"CELL missing in {fname.name}" - assert not any(c.startswith("CELL_") for c in codes), ( - f"CELL should not be split into per-condition channels in " - f"{fname.name}; got {codes}" - ) - - # Also verify end-to-end: STI 014 must be synthesised and CELL must appear - # in raw.event_id (not split) when reading the full file. - for fname in (egi_pause_fname, egi_eprime_pause_fname): - raw = read_raw_egi(fname, events_as_annotations=False) - assert "CELL" in raw.event_id, f"CELL missing from event_id in {fname.name}" - assert not any(k.startswith("CELL_") for k in raw.event_id), ( - f"CELL should not be split in {fname.name}; got {list(raw.event_id)}" +@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 "STI 014" in raw.ch_names, f"STI 014 not synthesised in {fname.name}" + assert expected in raw.annotations.description From 0f8db058a192a1b6a4d3a66ead84f28baca01cef Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 21 Jul 2026 13:27:24 -0700 Subject: [PATCH 4/6] ping From fb645548bc05d1a075fa4949000aac8dde4cd6dc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:26:40 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- mne/io/egi/tests/test_egi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py index 5eda5c5616a..2880518c356 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -18,7 +18,6 @@ from mne.datasets.testing import data_path, requires_testing_data from mne.io import read_evokeds_mff, read_raw_egi, read_raw_fif from mne.io.egi.egi import _combine_triggers -from mne.io.egi.events import _read_mff_events from mne.io.tests.test_raw import _test_raw_reader from mne.utils import copytree_rw, object_diff @@ -600,6 +599,7 @@ def test_egi_mff_bad_xml(tmp_path): # 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", From cf19ab1857e2c4eda6a2ecb5bebe6142edd28894 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Thu, 23 Jul 2026 16:49:48 +0530 Subject: [PATCH 6/6] Add changelog for cel# event key feature --- doc/changes/dev/14086.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14086.newfeature.rst 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`_.