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..90ecee39b7a 100644
--- a/doc/changes/names.inc
+++ b/doc/changes/names.inc
@@ -188,6 +188,7 @@
.. _Keith Doelling: https://github.com/kdoelling1919
.. _Konstantinos Tsilimparis: https://contsili.github.io/
.. _Kostiantyn Maksymenko: https://github.com/makkostya
+.. _Krishnaveni Parvataneni: https://github.com/kveni12
.. _Kristijan Armeni: https://github.com/kristijanarmeni
.. _Kyle Mathewson: https://github.com/kylemath
.. _Larry Eisenman: https://github.com/lneisenman
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..0f80e4d72c8 100644
--- a/mne/io/egi/tests/test_egi.py
+++ b/mne/io/egi/tests/test_egi.py
@@ -598,3 +598,45 @@ 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