Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/14086.newfeature.rst
Original file line number Diff line number Diff line change
@@ -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`_.
27 changes: 24 additions & 3 deletions mne/io/egi/egi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
``<keys>`` child element of each ``<event>`` element. This corresponds to
the ECI Event Data Stream "Key List" described in the
`EGI Amp Server Pro SDK User Guide
<https://www.egi.com/images/stories/manuals/amp-server-pro-sdk-3-0-network-apis-user-guide-rev-01.pdf>`__.
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(
Expand All @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion mne/io/egi/egimff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -381,6 +382,7 @@ def _read_raw_egi_mff(
preload,
channel_naming,
events_as_annotations=events_as_annotations,
event_key=event_key,
verbose=verbose,
)

Expand All @@ -402,6 +404,7 @@ def __init__(
channel_naming="E%d",
*,
events_as_annotations=True,
event_key=None,
verbose=None,
):
"""Init the RawMff class."""
Expand All @@ -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"]
Expand Down
18 changes: 15 additions & 3 deletions mne/io/egi/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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(
{
Expand Down
19 changes: 19 additions & 0 deletions mne/io/egi/tests/test_egi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``<keys>`` 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
Loading