From 478a1c4bd0f69fc9071b61cd3bf68d5505231acf Mon Sep 17 00:00:00 2001 From: Krishna Date: Fri, 26 Jun 2026 23:28:08 -0700 Subject: [PATCH 1/4] FIX: add recover_epochs fallback for EGI .mff epoch metadata mismatch (#13951) When epochs.xml sample counts don't match the binary signal data (e.g. improperly closed recordings, PSG/PNS async writes, impedance pauses), read_raw_egi now accepts recover_epochs=True to rebuild epoch boundaries from the actual binary block structure instead of raising RuntimeError. Default behavior (recover_epochs=False) is unchanged. --- mne/io/egi/egi.py | 11 ++++++++++ mne/io/egi/egimff.py | 38 +++++++++++++++++++++++++++------- mne/io/egi/tests/test_egi.py | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/mne/io/egi/egi.py b/mne/io/egi/egi.py index 73e93d39460..27ab2e4e0d1 100644 --- a/mne/io/egi/egi.py +++ b/mne/io/egi/egi.py @@ -100,6 +100,7 @@ def read_raw_egi( preload=False, channel_naming="E%d", *, + recover_epochs=False, events_as_annotations=True, verbose=None, ) -> "RawEGI": @@ -134,6 +135,15 @@ def read_raw_egi( .. versionadded:: 0.14.0 + recover_epochs : bool + If True and an MFF file has inconsistent epoch metadata (e.g., from an + improperly closed recording or interrupted acquisition), attempt to recover + the epoch structure from the binary signal data instead of raising an error. + A :class:`RuntimeWarning` is emitted when recovery is performed. + Default is False. + + .. versionadded:: 1.13.0 + events_as_annotations : bool If True, annotations are created from experiment events. If False (default), a synthetic trigger channel ``STI 014`` is created from experiment events. @@ -181,6 +191,7 @@ def read_raw_egi( exclude, preload, channel_naming, + recover_epochs=recover_epochs, events_as_annotations=events_as_annotations, verbose=verbose, ) diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index 0f35df646ff..4d2d06050b1 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -35,7 +35,7 @@ REFERENCE_NAMES = ("VREF", "Vertex Reference") -def _read_mff_header(filepath): +def _read_mff_header(filepath, recover_epochs=False): """Read mff header.""" _soft_import("mffpy", "reading EGI MFF data") from mffpy.xml_files import XML @@ -96,10 +96,29 @@ def _read_mff_header(filepath): or not (epochs["first_samps"][1:] >= epochs["last_samps"][:-1]).all() ) if bad: - raise RuntimeError( - "EGI epoch first/last samps could not be parsed:\n" - f"{list(epochs['first_samps'])}\n{list(epochs['last_samps'])}" + if not recover_epochs: + raise RuntimeError( + "EGI epoch first/last samps could not be parsed:\n" + f"{list(epochs['first_samps'])}\n{list(epochs['last_samps'])}" + ) + warn( + "EGI epoch metadata mismatch; recovering epoch structure from binary data", + RuntimeWarning, + stacklevel=2, + ) + # Rebuild epoch boundaries from the actual binary block sizes + block_sizes = signal_blocks["samples_block"] + total_samps = int(block_sizes.sum()) + n_epochs = len(epochs["first_samps"]) + samps_per_epoch = total_samps // n_epochs + epochs["first_samps"] = np.array( + [i * samps_per_epoch for i in range(n_epochs)], dtype=np.int64 + ) + epochs["last_samps"] = np.array( + [min((i + 1) * samps_per_epoch, total_samps) for i in range(n_epochs)], + dtype=np.int64, ) + n_samps_epochs = (epochs["last_samps"] - epochs["first_samps"]).sum() summaryinfo.update(epochs) disk_samps = np.full(epochs["last_samps"][-1], -1) @@ -193,13 +212,15 @@ def _read_mff_header(filepath): return summaryinfo -def _read_header(input_fname): +def _read_header(input_fname, recover_epochs=False): """Obtain the headers from the file package mff. Parameters ---------- input_fname : path-like Path for the file + recover_epochs : bool + If True, recover epoch structure from binary data when metadata is inconsistent. Returns ------- @@ -207,7 +228,7 @@ def _read_header(input_fname): Main headers set. """ input_fname = str(input_fname) # cast to str any Paths - mff_hdr = _read_mff_header(input_fname) + mff_hdr = _read_mff_header(input_fname, recover_epochs=recover_epochs) with open(input_fname + "/signal1.bin", "rb") as fid: version = np.fromfile(fid, np.int32, 1)[0] """ @@ -346,6 +367,7 @@ def _read_raw_egi_mff( preload=False, channel_naming="E%d", *, + recover_epochs=False, events_as_annotations=True, verbose=None, ): @@ -358,6 +380,7 @@ def _read_raw_egi_mff( exclude, preload, channel_naming, + recover_epochs=recover_epochs, events_as_annotations=events_as_annotations, verbose=verbose, ) @@ -379,6 +402,7 @@ def __init__( preload=False, channel_naming="E%d", *, + recover_epochs=False, events_as_annotations=True, verbose=None, ): @@ -393,7 +417,7 @@ def __init__( ) ) logger.info(f"Reading EGI MFF Header from {input_fname}...") - egi_info = _read_header(input_fname) + egi_info = _read_header(input_fname, recover_epochs=recover_epochs) if eog is None: eog = [] if misc is None: diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py index 38522b94e24..1e502b229fc 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -598,3 +598,43 @@ 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_recover_epochs_raises_by_default(tmp_path): + """Test that mismatched epoch metadata raises RuntimeError by default.""" + import re as _re + + mff_fname = copytree_rw(egi_mff_fname, tmp_path / "test_egi_bad_epochs.mff") + epochs_xml = mff_fname / "epochs.xml" + content = epochs_xml.read_text(encoding="utf-8") + + def _corrupt_end(m): + val = int(m.group(1)) + return f"{val * 100}" + + bad_content = _re.sub(r"(\d+)", _corrupt_end, content) + epochs_xml.write_text(bad_content, encoding="utf-8") + with pytest.raises(RuntimeError, match="EGI epoch first/last samps could not be parsed"): + read_raw_egi(mff_fname, recover_epochs=False) + + +@requires_testing_data +def test_recover_epochs_succeeds_with_flag(tmp_path): + """Test that recover_epochs=True loads a file with mismatched epoch metadata.""" + import re as _re + + mff_fname = copytree_rw(egi_mff_fname, tmp_path / "test_egi_recover_epochs.mff") + epochs_xml = mff_fname / "epochs.xml" + content = epochs_xml.read_text(encoding="utf-8") + + def _corrupt_end(m): + val = int(m.group(1)) + return f"{val * 100}" + + bad_content = _re.sub(r"(\d+)", _corrupt_end, content) + epochs_xml.write_text(bad_content, encoding="utf-8") + with pytest.warns(RuntimeWarning, match="EGI epoch metadata mismatch"): + raw = read_raw_egi(mff_fname, recover_epochs=True) + assert raw is not None + assert raw.n_times > 0 From e87d2a6d7df08388318f703be85255ef37a76eb5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:28:44 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/io/egi/tests/test_egi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py index 1e502b229fc..0f80e4d72c8 100644 --- a/mne/io/egi/tests/test_egi.py +++ b/mne/io/egi/tests/test_egi.py @@ -615,7 +615,9 @@ def _corrupt_end(m): bad_content = _re.sub(r"(\d+)", _corrupt_end, content) epochs_xml.write_text(bad_content, encoding="utf-8") - with pytest.raises(RuntimeError, match="EGI epoch first/last samps could not be parsed"): + with pytest.raises( + RuntimeError, match="EGI epoch first/last samps could not be parsed" + ): read_raw_egi(mff_fname, recover_epochs=False) From 94e36a9b026b98ffd214cc6412388654673680a0 Mon Sep 17 00:00:00 2001 From: Krishna Date: Fri, 26 Jun 2026 23:32:48 -0700 Subject: [PATCH 3/4] STY/DOC: fix line length, add changelog entry and author name --- doc/changes/dev/13992.bugfix.rst | 1 + doc/changes/names.inc | 1 + 2 files changed, 2 insertions(+) create mode 100644 doc/changes/dev/13992.bugfix.rst diff --git a/doc/changes/dev/13992.bugfix.rst b/doc/changes/dev/13992.bugfix.rst new file mode 100644 index 00000000000..f4b8ed0a4b3 --- /dev/null +++ b/doc/changes/dev/13992.bugfix.rst @@ -0,0 +1 @@ +Add ``recover_epochs`` parameter to :func:`~mne.io.read_raw_egi` to gracefully recover epoch structure from binary signal data when ``epochs.xml`` metadata is inconsistent (e.g., improperly closed or interrupted EGI recordings), by :newcontrib:`Krishnaveni Parvataneni`. diff --git a/doc/changes/names.inc b/doc/changes/names.inc index 2bf4fc9fdd7..9a07fe83e3b 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -189,6 +189,7 @@ .. _Konstantinos Tsilimparis: https://contsili.github.io/ .. _Kostiantyn Maksymenko: https://github.com/makkostya .. _Kristijan Armeni: https://github.com/kristijanarmeni +.. _Krishnaveni Parvataneni: https://github.com/kveni12 .. _Kyle Mathewson: https://github.com/kylemath .. _Larry Eisenman: https://github.com/lneisenman .. _Lau Møller Andersen: https://github.com/ualsbombe From b91080e594fa4b0f741e358a552cd59e138a272f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:34:21 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/changes/names.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/names.inc b/doc/changes/names.inc index 9a07fe83e3b..90ecee39b7a 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -188,8 +188,8 @@ .. _Keith Doelling: https://github.com/kdoelling1919 .. _Konstantinos Tsilimparis: https://contsili.github.io/ .. _Kostiantyn Maksymenko: https://github.com/makkostya -.. _Kristijan Armeni: https://github.com/kristijanarmeni .. _Krishnaveni Parvataneni: https://github.com/kveni12 +.. _Kristijan Armeni: https://github.com/kristijanarmeni .. _Kyle Mathewson: https://github.com/kylemath .. _Larry Eisenman: https://github.com/lneisenman .. _Lau Møller Andersen: https://github.com/ualsbombe