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/14156.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:func:`mne.io.read_raw_egi` now reads bad channels from ``categories.xml`` (when present in the MFF directory) and populates :attr:`mne.Info.bads` with channels marked ``exclusion="badChannels"`` in NetStation. By `Pragnya Khandelwal`_.
44 changes: 44 additions & 0 deletions mne/io/egi/egimff.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,42 @@ def _disk_range_to_epochs(egi_info, disk_start, disk_stop):
yield ei, t0, dt, ov_start - disk_start, ov_stop - disk_start


def _read_channel_status_bads(filepath, ch_names, pns_names):
"""Return bad channel names from categories.xml channelStatus, if present.

EGI NetStation writes per-epoch bad-channel lists into ``categories.xml``
as ``<channelStatus>`` elements. This function collects the union of all
channels marked ``exclusion="badChannels"`` across every category and
segment and maps them back to MNE channel names.

Returns an empty list when ``categories.xml`` is absent or unparsable.
"""
cats_path = op.join(filepath, "categories.xml")
if not op.isfile(cats_path):
return []
from mffpy.xml_files import XML

try:
cats_obj = XML.from_file(cats_path)
except Exception:
return []
bads = set()
for segments in cats_obj.categories.values():
for seg in segments:
for entry in seg.get("channelStatus") or []:
if entry["exclusion"] != "badChannels":
continue
if entry["signalBin"] == 1:
for ch in entry["channels"]:
if 1 <= ch <= len(ch_names):
bads.add(ch_names[ch - 1])
elif entry["signalBin"] == 2:
for ch in entry["channels"]:
if 1 <= ch <= len(pns_names):
bads.add(pns_names[ch - 1])
return sorted(bads)


def _read_mff_header(filepath):
"""Read mff header."""
_soft_import("mffpy", "reading EGI MFF data")
Expand Down Expand Up @@ -514,6 +550,14 @@ def __init__(
if chan["kind"] == FIFF.FIFFV_EEG_CH:
chan["loc"][3:6] = ref_coords

# Mark bad channels from categories.xml channelStatus if present
bads = _read_channel_status_bads(
input_fname, ch_names, egi_info.get("pns_names", [])
)
if bads:
with info._unlock():
info["bads"] = bads

file_bin = op.join(input_fname, egi_info["eeg_fname"])
egi_info["egi_events"] = egi_events
egi_info["mff_path"] = input_fname
Expand Down
29 changes: 29 additions & 0 deletions mne/io/egi/tests/test_egi.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,35 @@ def test_egi_mff_bad_xml(tmp_path):
assert "DIN1" in raw.annotations.description


@requires_testing_data
def test_egi_mff_channel_status(tmp_path):
"""Test that bad channels from categories.xml channelStatus are read."""
mff_fname = copytree_rw(egi_pause_fname, tmp_path / "paused_status.mff")
# Minimal categories.xml marking EEG channels 5 and 23 as bad
cats_xml = """\
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<categories xmlns="http://www.egi.com/categories_mff">
<cat>
<name>Recording</name>
<segments>
<seg status="unedited">
<beginTime>0</beginTime>
<endTime>1300000</endTime>
<evtBegin>0</evtBegin>
<evtEnd>0</evtEnd>
<channelStatus>
<channels signalBin="1" exclusion="badChannels">5 23</channels>
</channelStatus>
</seg>
</segments>
</cat>
</categories>
"""
(mff_fname / "categories.xml").write_text(cats_xml, encoding="utf-8")
raw = read_raw_egi(mff_fname, events_as_annotations=False, verbose=False)
assert raw.info["bads"] == ["E23", "E5"] # sorted alphabetically


@requires_testing_data
@pytest.mark.parametrize(
"fname, expected",
Expand Down
Loading