Skip to content

Commit d314349

Browse files
scott-hubertyPragnyaKhandelwal
authored andcommitted
Event Keys
1 parent c034163 commit d314349

4 files changed

Lines changed: 60 additions & 44 deletions

File tree

mne/io/egi/egi.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def read_raw_egi(
101101
channel_naming="E%d",
102102
*,
103103
events_as_annotations=True,
104+
event_key=None,
104105
verbose=None,
105106
) -> "RawEGI":
106107
"""Read EGI simple binary as raw object.
@@ -141,6 +142,15 @@ def read_raw_egi(
141142
The default will change from False to True in version 1.9.
142143
143144
.. versionadded:: 1.8.0
145+
event_key : str | None
146+
The MFF event key whose value is appended to annotation descriptions and extras
147+
when ``events_as_annotations=True``, and to stim channel names when
148+
``events_as_annotations=False``. For example, ``event_key='cel#'`` will convert
149+
an MFF event code ``'stim'`` with ``cel#=1`` to ``'stim_1'``. These will also
150+
appear as ``'event_key_cel#': 1`` in annotations.extras
151+
Only supported for MFF files.
152+
153+
.. versionadded:: 1.11.0
144154
%(verbose)s
145155
146156
Returns
@@ -155,22 +165,31 @@ def read_raw_egi(
155165
156166
Notes
157167
-----
158-
When ``events_from_annotations=True``, event codes on stimulus channels like
168+
When ``events_as_annotations=True``, event codes on stimulus channels like
159169
``DIN1`` are stored as annotations with the ``description`` set to the stimulus
160170
channel name.
161171
162-
When ``events_from_annotations=False`` and events are present on the included
163-
stimulus channels, a new stim channel ``STI014`` will be synthesized from the
172+
When ``events_as_annotations=False`` and events are present on the included
173+
stimulus channels, a new stim channel ``STI 014`` will be synthesized from the
164174
events. It will contain 1-sample pulses where the Netstation file had event
165175
timestamps. A ``raw.event_id`` dictionary is added to the raw object that will have
166176
arbitrary sequential integer IDs for the events. This will fail if any timestamps
167177
are duplicated. The ``event_id`` will also not survive a save/load roundtrip.
168178
169179
For these reasons, it is recommended to use ``events_as_annotations=True``.
180+
181+
MFF event track XML files can store additional metadata in a
182+
``<keys>`` child element of each ``<event>`` element. This corresponds to
183+
the ECI Event Data Stream "Key List" described in the
184+
`EGI Amp Server Pro SDK User Guide
185+
<https://www.egi.com/images/stories/manuals/amp-server-pro-sdk-3-0-network-apis-user-guide-rev-01.pdf>`__.
186+
For example, E-Prime driven experiments sometimes store the experimental condition
187+
in a ``'cel#'`` event key.
170188
"""
171189
_validate_type(input_fname, "path-like", "input_fname")
172190
input_fname = str(input_fname)
173191
_validate_type(events_as_annotations, bool, "events_as_annotations")
192+
_validate_type(event_key, (str, None), "event_key")
174193

175194
if input_fname.rstrip("/\\").endswith(".mff"): # allows .mff or .mff/
176195
return _read_raw_egi_mff(
@@ -182,8 +201,10 @@ def read_raw_egi(
182201
preload,
183202
channel_naming,
184203
events_as_annotations=events_as_annotations,
204+
event_key=event_key,
185205
verbose=verbose,
186206
)
207+
187208
return RawEGI(
188209
input_fname,
189210
eog,

mne/io/egi/egimff.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ def _read_raw_egi_mff(
369369
channel_naming="E%d",
370370
*,
371371
events_as_annotations=True,
372+
event_key=None,
372373
verbose=None,
373374
):
374375
"""Read EGI mff binary as raw object."""
@@ -381,6 +382,7 @@ def _read_raw_egi_mff(
381382
preload,
382383
channel_naming,
383384
events_as_annotations=events_as_annotations,
385+
event_key=event_key,
384386
verbose=verbose,
385387
)
386388

@@ -402,6 +404,7 @@ def __init__(
402404
channel_naming="E%d",
403405
*,
404406
events_as_annotations=True,
407+
event_key=None,
405408
verbose=None,
406409
):
407410
"""Init the RawMff class."""
@@ -422,7 +425,9 @@ def __init__(
422425
misc = np.where(np.array(egi_info["chan_type"]) != "eeg")[0].tolist()
423426

424427
logger.info(" Reading events ...")
425-
egi_events, egi_info, mff_events = _read_events(input_fname, egi_info)
428+
egi_events, egi_info, mff_events = _read_events(
429+
input_fname, egi_info, event_key=event_key
430+
)
426431
cals = np.array([CAL_SCALES[t] for t in egi_info["chan_unit"]])
427432
logger.info(" Assembling measurement info ...")
428433
event_codes = egi_info["event_codes"]

mne/io/egi/events.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ...utils import _soft_import, _validate_type, logger, warn
1111

1212

13-
def _read_events(input_fname, info):
13+
def _read_events(input_fname, info, *, event_key=None):
1414
"""Read events for the record.
1515
1616
Parameters
@@ -22,7 +22,7 @@ def _read_events(input_fname, info):
2222
"""
2323
n_samples = info["last_samps"][-1]
2424
mff_events, event_codes = _read_mff_events(
25-
input_fname, info["sfreq"], info["meas_dt_local"]
25+
input_fname, info["sfreq"], info["meas_dt_local"], event_key=event_key
2626
)
2727
info["n_events"] = len(event_codes)
2828
info["event_codes"] = event_codes
@@ -36,7 +36,7 @@ def _read_events(input_fname, info):
3636
return events, info, mff_events
3737

3838

39-
def _read_mff_events(filename, sfreq, start_time):
39+
def _read_mff_events(filename, sfreq, start_time, *, event_key=None):
4040
"""Extract the events with mffpy."""
4141
mffpy = _soft_import("mffpy", purpose="reading EGI MFF data", min_version="0.11")
4242
from mffpy.xml_files import XML
@@ -69,9 +69,10 @@ def _read_mff_events(filename, sfreq, start_time):
6969
for track in tracks:
7070
for event in track.events:
7171
code_str = event["code"]
72-
cel = event.get("keys", {}).get("cel#")
73-
if cel is not None:
74-
code_str = f"{code_str}_{int(cel)}"
72+
keys = event.get("keys", {})
73+
key_value = keys.get(event_key) if event_key is not None else None
74+
if key_value is not None:
75+
code_str = f"{code_str}_{key_value}"
7576
if code_str not in code:
7677
code.append(code_str)
7778

@@ -86,6 +87,14 @@ def _read_mff_events(filename, sfreq, start_time):
8687
)
8788
else:
8889
extras = {}
90+
extras.update(
91+
{
92+
f"event_key_{key}": value.item()
93+
if isinstance(value, np.generic)
94+
else value
95+
for key, value in keys.items()
96+
}
97+
)
8998

9099
markers.append(
91100
{

mne/io/egi/tests/test_egi.py

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -600,39 +600,20 @@ def test_egi_mff_bad_xml(tmp_path):
600600
# little check that the bad XML doesn't affect the parsing of other xml files
601601
assert "DIN1" in raw.annotations.description
602602

603-
604603
@requires_testing_data
605-
def test_egi_mff_cel_condition_codes():
606-
"""Test that cel# compound codes are only generated for repeated trial events.
607-
608-
EGI records a ``cel#`` key on each event to identify the experimental
609-
condition. When a code appears multiple times per condition (e.g. ``stm+``
610-
firing on every trial) ``read_raw_egi`` should append the cel# value and
611-
produce codes like ``stm+_1`` / ``stm+_2``.
612-
613-
However, some files use a once-per-condition-block ``CELL`` marker where
614-
each cel# value (1–5) fires exactly once. Splitting those into separate
615-
channels would create consecutive-sample trigger values that confuse
616-
``find_events`` and prevent ``STI 014`` from being synthesised. The reader
617-
must leave such codes unsplit.
618-
"""
619-
import mffpy
620-
621-
for fname in (egi_pause_fname, egi_eprime_pause_fname):
622-
r = mffpy.Reader(str(fname))
623-
_, codes = _read_mff_events(str(fname), 250.0, r.startdatetime)
624-
assert "CELL" in codes, f"CELL missing in {fname.name}"
625-
assert not any(c.startswith("CELL_") for c in codes), (
626-
f"CELL should not be split into per-condition channels in "
627-
f"{fname.name}; got {codes}"
628-
)
629-
630-
# Also verify end-to-end: STI 014 must be synthesised and CELL must appear
631-
# in raw.event_id (not split) when reading the full file.
632-
for fname in (egi_pause_fname, egi_eprime_pause_fname):
633-
raw = read_raw_egi(fname, events_as_annotations=False)
634-
assert "CELL" in raw.event_id, f"CELL missing from event_id in {fname.name}"
635-
assert not any(k.startswith("CELL_") for k in raw.event_id), (
636-
f"CELL should not be split in {fname.name}; got {list(raw.event_id)}"
604+
@pytest.mark.parametrize(
605+
"fname, expected",
606+
[pytest.param(egi_pause_fname, "AM40_3", id="paused")],
607+
)
608+
def test_read_event_keys(fname, expected):
609+
"""Test event metadata extraction from``<keys>`` child elements."""
610+
with pytest.warns(RuntimeWarning, match="Acquisition skips detected"):
611+
raw = _test_raw_reader(
612+
read_raw_egi,
613+
input_fname=fname,
614+
test_scaling=False,
615+
test_rank="less",
616+
events_as_annotations=True,
617+
event_key="cel#",
637618
)
638-
assert "STI 014" in raw.ch_names, f"STI 014 not synthesised in {fname.name}"
619+
assert expected in raw.annotations.description

0 commit comments

Comments
 (0)