Skip to content

Commit eff72db

Browse files
FIX: Make brainvision overrides robust to missing header keys; cover all spec-required keys
The override branches called cfg.get(..., key) purely to log the original header value before announcing the override; that lookup raised NoOptionError exactly in the case the override was meant to recover from. Wrap the log-only lookups (DataFile, MarkerFile, NumberOfChannels) and the required SamplingInterval lookup in _aux_hdr_info so the override actually takes effect when the header lacks the key. Extend overrides to every spec-required header key with three new options: data_orientation ([Common Infos] DataOrientation), data_format ([Common Infos] DataFormat), and binary_format ([Binary Infos] BinaryFormat). Treat a missing or empty MarkerFile= entry as "no markers" (per the BV Core Data Format spec, MarkerFile= is optional). Collapse the six per-key override+log+fallback blocks behind one _hdr_get helper, and refactor _validate_overrides into three dispatch tables (type / range / enum) plus the two genuinely irregular cases (marker_fname=False sentinel, ch_names per-element + uniqueness). Tests: parametrize test_overrides_recover_missing_header_keys across all seven file-/header-key keys; add validation cases for the three new enum keys; add test_empty_marker_file_means_no_markers for the spec-compliant empty-MarkerFile= path.
1 parent 8c70f31 commit eff72db

4 files changed

Lines changed: 152 additions & 46 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Add ``overrides`` parameter to :func:`mne.io.read_raw_brainvision` for reading non-spec-compliant ``.vhdr`` files where the header contradicts the actual layout (e.g. renamed BIDS siblings, missing ``MarkerFile=``, truncated ``[Channel Infos]``). Accepts a dict with keys ``data_fname``, ``marker_fname``, ``n_channels``, ``sfreq``, ``ch_names``, and ``units_fallback``; see the function docstring for details, by `Bruno Aristimunha`_.
1+
Add ``overrides`` parameter to :func:`mne.io.read_raw_brainvision` for reading non-spec-compliant ``.vhdr`` files where the header contradicts the actual layout (e.g. renamed BIDS siblings, missing ``MarkerFile=``, truncated ``[Channel Infos]``). Accepts a dict with keys ``data_fname``, ``marker_fname``, ``n_channels``, ``sfreq``, ``ch_names``, ``units_fallback``, ``data_orientation``, ``data_format``, and ``binary_format``; see the function docstring for details. Missing or empty ``MarkerFile=`` is now also tolerated natively (per the BV Core Data Format spec) by `Bruno Aristimunha`_.

mne/io/brainvision/brainvision.py

Lines changed: 83 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from ...transforms import _sph_to_cart
2323
from ...utils import (
2424
_check_dict_keys,
25+
_check_option,
2526
_check_range,
2627
_DefaultEventParser,
2728
_validate_type,
@@ -405,6 +406,7 @@ def _check_bv_version(header, kind):
405406

406407
_OVERRIDES_VALID_KEYS = frozenset(
407408
{"data_fname", "marker_fname", "n_channels", "sfreq", "ch_names", "units_fallback"}
409+
| {"data_orientation", "data_format", "binary_format"}
408410
)
409411

410412

@@ -416,31 +418,53 @@ def _validate_overrides(overrides):
416418
_check_dict_keys(
417419
overrides, _OVERRIDES_VALID_KEYS, "override key(s)", "valid override keys"
418420
)
419-
if "data_fname" in overrides:
420-
_validate_type(overrides["data_fname"], "path-like", "overrides['data_fname']")
421+
422+
def _name(key):
423+
return f"overrides['{key}']"
424+
425+
type_specs = {
426+
"data_fname": "path-like",
427+
"n_channels": "int-like",
428+
"sfreq": "numeric",
429+
"ch_names": (list, tuple),
430+
"units_fallback": str,
431+
}
432+
for key, type_ in type_specs.items():
433+
if key in overrides:
434+
_validate_type(overrides[key], type_, _name(key))
435+
436+
range_specs = {
437+
"n_channels": dict(min_val=1, max_val=np.inf),
438+
"sfreq": dict(min_val=0, max_val=np.inf, min_inclusive=False),
439+
}
440+
for key, kw in range_specs.items():
441+
if key in overrides:
442+
_check_range(overrides[key], name=_name(key), **kw)
443+
444+
enum_specs = {
445+
"data_orientation": tuple(_orientation_dict),
446+
"data_format": ("BINARY", "ASCII"),
447+
"binary_format": tuple(_fmt_dict),
448+
}
449+
for key, allowed in enum_specs.items():
450+
if key in overrides:
451+
_check_option(_name(key), overrides[key], allowed)
452+
453+
# marker_fname accepts False as a sentinel for "skip annotations"
421454
if "marker_fname" in overrides and overrides["marker_fname"] is not False:
422455
_validate_type(
423456
overrides["marker_fname"],
424457
"path-like",
425-
"overrides['marker_fname']",
458+
_name("marker_fname"),
426459
extra="(or False to skip annotation reading)",
427460
)
428-
if "n_channels" in overrides:
429-
_validate_type(overrides["n_channels"], "int-like", "overrides['n_channels']")
430-
_check_range(overrides["n_channels"], 1, np.inf, "overrides['n_channels']")
431-
if "sfreq" in overrides:
432-
_validate_type(overrides["sfreq"], "numeric", "overrides['sfreq']")
433-
_check_range(
434-
overrides["sfreq"], 0, np.inf, "overrides['sfreq']", min_inclusive=False
435-
)
461+
# ch_names: per-element type + uniqueness
436462
if "ch_names" in overrides:
437-
_validate_type(overrides["ch_names"], (list, tuple), "overrides['ch_names']")
438463
for i, name in enumerate(overrides["ch_names"]):
439464
_validate_type(name, str, f"overrides['ch_names'][{i}]")
440465
if len(set(overrides["ch_names"])) != len(overrides["ch_names"]):
441466
raise ValueError("overrides['ch_names'] must contain unique names")
442-
if "units_fallback" in overrides:
443-
_validate_type(overrides["units_fallback"], str, "overrides['units_fallback']")
467+
444468
return overrides
445469

446470

@@ -485,6 +509,27 @@ def _str_to_meas_date(date_str):
485509
return meas_date
486510

487511

512+
_MISSING = object()
513+
514+
515+
def _hdr_get(cfg, section, option, overrides, key, *, getter="get", missing=_MISSING):
516+
"""Read header value or apply ``overrides[key]``; log when overriding."""
517+
fn = getattr(cfg, getter)
518+
if key in overrides:
519+
try:
520+
original = fn(section, option)
521+
except (configparser.NoOptionError, configparser.NoSectionError):
522+
original = "(missing)"
523+
logger.info(f"Overriding {option} {original!r} -> {overrides[key]!r}")
524+
return overrides[key]
525+
try:
526+
return fn(section, option)
527+
except (configparser.NoOptionError, configparser.NoSectionError):
528+
if missing is _MISSING:
529+
raise
530+
return missing
531+
532+
488533
def _aux_hdr_info(hdr_fname):
489534
"""Aux function for _get_hdr_info."""
490535
with open(hdr_fname, "rb") as f:
@@ -534,7 +579,10 @@ def _aux_hdr_info(hdr_fname):
534579

535580
# get sampling info
536581
# Sampling interval is given in microsec
537-
sfreq = 1e6 / cfg.getfloat(cinfostr, "SamplingInterval")
582+
try:
583+
sfreq = 1e6 / cfg.getfloat(cinfostr, "SamplingInterval")
584+
except configparser.NoOptionError:
585+
sfreq = float("nan") # caller may recover via overrides=dict(sfreq=...)
538586
info = _empty_info(sfreq)
539587
info._unlocked = False
540588
return settings, cfg, cinfostr, info
@@ -597,15 +645,17 @@ def _get_hdr_info(hdr_fname, eog, misc, scale, overrides=None):
597645
if "sfreq" in overrides:
598646
logger.info(f"Overriding sfreq {info['sfreq']} -> {overrides['sfreq']} Hz")
599647
info["sfreq"] = overrides["sfreq"]
648+
elif np.isnan(info["sfreq"]):
649+
raise configparser.NoOptionError("samplinginterval", cinfostr)
600650

601-
order = cfg.get(cinfostr, "DataOrientation")
651+
order = _hdr_get(cfg, cinfostr, "DataOrientation", overrides, "data_orientation")
602652
if order not in _orientation_dict:
603653
raise NotImplementedError(f"Data Orientation {order} is not supported")
604654
order = _orientation_dict[order]
605655

606-
data_format = cfg.get(cinfostr, "DataFormat")
656+
data_format = _hdr_get(cfg, cinfostr, "DataFormat", overrides, "data_format")
607657
if data_format == "BINARY":
608-
fmt = cfg.get("Binary Infos", "BinaryFormat")
658+
fmt = _hdr_get(cfg, "Binary Infos", "BinaryFormat", overrides, "binary_format")
609659
if fmt not in _fmt_dict:
610660
raise NotImplementedError(f"Datatype {fmt} is not supported")
611661
fmt = _fmt_dict[fmt]
@@ -619,26 +669,19 @@ def _get_hdr_info(hdr_fname, eog, misc, scale, overrides=None):
619669

620670
# locate EEG binary file and marker file for the stim channel
621671
path = op.dirname(hdr_fname)
622-
if "data_fname" in overrides:
623-
data_fname = op.join(path, overrides["data_fname"])
624-
logger.info(
625-
f"Overriding DataFile {cfg.get(cinfostr, 'DataFile')!r} -> "
626-
f"{overrides['data_fname']!r}"
627-
)
628-
else:
629-
data_fname = op.join(path, cfg.get(cinfostr, "DataFile"))
630-
631-
if "marker_fname" in overrides:
632-
mrk_override = overrides["marker_fname"]
633-
header_mrk = cfg.get(cinfostr, "MarkerFile")
634-
if mrk_override is False:
635-
mrk_fname = None
636-
logger.info(f"Overriding MarkerFile {header_mrk!r} -> skipped")
637-
else:
638-
mrk_fname = op.join(path, mrk_override)
639-
logger.info(f"Overriding MarkerFile {header_mrk!r} -> {mrk_override!r}")
672+
data_fname = op.join(
673+
path, _hdr_get(cfg, cinfostr, "DataFile", overrides, "data_fname")
674+
)
675+
676+
# MarkerFile is optional per BV spec: missing/empty/override=False = no markers
677+
if overrides.get("marker_fname") is False:
678+
logger.info("Overriding MarkerFile -> skipped")
679+
mrk_fname = None
640680
else:
641-
mrk_fname = op.join(path, cfg.get(cinfostr, "MarkerFile"))
681+
mrk = _hdr_get(
682+
cfg, cinfostr, "MarkerFile", overrides, "marker_fname", missing=""
683+
)
684+
mrk_fname = op.join(path, mrk) if mrk else None
642685

643686
# Try to get measurement date from marker file
644687
# Usually saved with a marker "New Segment", see BrainVision documentation
@@ -657,14 +700,9 @@ def _get_hdr_info(hdr_fname, eog, misc, scale, overrides=None):
657700
break
658701

659702
# load channel labels
660-
if "n_channels" in overrides:
661-
nchan = overrides["n_channels"]
662-
logger.info(
663-
f"Overriding NumberOfChannels "
664-
f"{cfg.getint(cinfostr, 'NumberOfChannels')} -> {nchan}"
665-
)
666-
else:
667-
nchan = cfg.getint(cinfostr, "NumberOfChannels")
703+
nchan = _hdr_get(
704+
cfg, cinfostr, "NumberOfChannels", overrides, "n_channels", getter="getint"
705+
)
668706
if ahdr_format:
669707
# add one fake channel for ahdr format
670708
nchan += 1

mne/io/brainvision/tests/test_brainvision.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# License: BSD-3-Clause
55
# Copyright the MNE-Python contributors.
66

7+
import configparser
78
import datetime
89
import re
910
import shutil
@@ -710,6 +711,9 @@ def test_ignore_marker_types():
710711
({"ch_names": ["A", "A", "B"]}, ValueError, "must contain unique names"),
711712
({"ch_names": list("abcde")}, ValueError, "length 5 but the file declares"),
712713
({"units_fallback": 7}, TypeError, "units_fallback.*str"),
714+
({"data_orientation": "SIDEWAYS"}, ValueError, "Invalid.*data_orientation"),
715+
({"data_format": "PARQUET"}, ValueError, "Invalid.*data_format"),
716+
({"binary_format": "FLOAT_64"}, ValueError, "Invalid.*binary_format"),
713717
],
714718
)
715719
def test_overrides_validation(overrides, exc, match):
@@ -781,6 +785,63 @@ def test_overrides_file_paths(tmp_path):
781785
assert raw.info["meas_date"] is None
782786

783787

788+
@pytest.mark.parametrize(
789+
"key, override, header_key, recoverable, check",
790+
[
791+
# MarkerFile is optional per BV spec → recoverable without an override
792+
(
793+
"marker_fname",
794+
False,
795+
"MarkerFile",
796+
True,
797+
lambda r: r.info["meas_date"] is None,
798+
),
799+
# The rest are spec-required → must be supplied via overrides
800+
("data_fname", "test.eeg", "DataFile", False, lambda r: r.info["nchan"] == 32),
801+
("n_channels", 32, "NumberOfChannels", False, lambda r: r.info["nchan"] == 32),
802+
(
803+
"sfreq",
804+
1000.0,
805+
"SamplingInterval",
806+
False,
807+
lambda r: r.info["sfreq"] == 1000.0,
808+
),
809+
("data_orientation", "MULTIPLEXED", "DataOrientation", False, lambda r: True),
810+
("data_format", "BINARY", "DataFormat", False, lambda r: True),
811+
("binary_format", "INT_16", "BinaryFormat", False, lambda r: True),
812+
],
813+
)
814+
def test_overrides_recover_missing_header_keys(
815+
tmp_path, key, override, header_key, recoverable, check
816+
):
817+
"""Overrides recover when the header is missing the corresponding key."""
818+
shutil.copy(eeg_path, tmp_path / "test.eeg")
819+
shutil.copy(vmrk_path, tmp_path / "test.vmrk")
820+
use_vhdr = tmp_path / f"no_{key}.vhdr"
821+
skip = (f"{header_key}=".encode(),)
822+
with open(vhdr_path, "rb") as fin, open(use_vhdr, "wb") as fout:
823+
fout.writelines(line for line in fin if not line.startswith(skip))
824+
if recoverable:
825+
assert check(read_raw_brainvision(use_vhdr))
826+
else:
827+
with pytest.raises(configparser.NoOptionError, match=header_key.lower()):
828+
read_raw_brainvision(use_vhdr)
829+
assert check(
830+
read_raw_brainvision(use_vhdr, overrides={key: override}, preload=True)
831+
)
832+
833+
834+
def test_empty_marker_file_means_no_markers(tmp_path):
835+
"""Empty ``MarkerFile=`` value is spec-compliant and means no markers."""
836+
shutil.copy(eeg_path, tmp_path / "test.eeg")
837+
use_vhdr = tmp_path / "empty_marker.vhdr"
838+
with open(vhdr_path, "rb") as fin, open(use_vhdr, "wb") as fout:
839+
for line in fin:
840+
fout.write(b"MarkerFile=\n" if line.startswith(b"MarkerFile=") else line)
841+
raw = read_raw_brainvision(use_vhdr)
842+
assert len(raw.annotations) == 0 and raw.info["meas_date"] is None
843+
844+
784845
@testing.requires_testing_data
785846
def test_read_vhdr_annotations_and_events(tmp_path):
786847
"""Test load brainvision annotations and parse them to events."""

mne/utils/docs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
600600
Recovers an incomplete ``[Channel Infos]`` section by filling missing
601601
entries with ``resolution=1.0`` and this unit; missing names become
602602
``"Ch<N>"``.
603+
``"data_orientation"`` (``"MULTIPLEXED"`` | ``"VECTORIZED"``)
604+
Replaces ``[Common Infos] DataOrientation=``.
605+
``"data_format"`` (``"BINARY"`` | ``"ASCII"``)
606+
Replaces ``[Common Infos] DataFormat=``.
607+
``"binary_format"`` (``"INT_16"`` | ``"INT_32"`` | ``"IEEE_FLOAT_32"``)
608+
Replaces ``[Binary Infos] BinaryFormat=``. Only consulted when the
609+
effective ``DataFormat`` is ``"BINARY"``.
603610
604611
Each applied override is logged at INFO level. Unknown keys raise
605612
``ValueError``.

0 commit comments

Comments
 (0)