From 97d69252d48e336adb3eead6e0d78c761bda6beb Mon Sep 17 00:00:00 2001 From: Hansuja Date: Sun, 26 Apr 2026 17:16:03 +0530 Subject: [PATCH 1/7] Fix SourceChGain/SourceChOffset handling in BCI2k reader --- mne/io/bci2k/bci2k.py | 57 ++++++++++++++++++++++++++------ mne/io/bci2k/tests/test_bci2k.py | 22 ++++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/mne/io/bci2k/bci2k.py b/mne/io/bci2k/bci2k.py index 39cd709a11a..4d5805611f9 100644 --- a/mne/io/bci2k/bci2k.py +++ b/mne/io/bci2k/bci2k.py @@ -11,16 +11,23 @@ from ...utils import verbose from ..base import BaseRaw +_VOLT_SCALE = {"v": 1.0, "mv": 1e-3, "muv": 1e-6, "uv": 1e-6, "nv": 1e-9} +_FREQ_SCALE = {"hz": 1.0, "khz": 1e3} -def _parse_sampling_rate(val): - # Accept e.g. "256", "256Hz", "256.0 Hz" - text = str(val).strip() - text = re.sub(r"\s*Hz\s*$", "", text, flags=re.IGNORECASE) - # Grab the first float-looking token - m = re.search(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", text) + +def _parse_value_with_unit(token, unit_scale=None): + """Split a numeric token with optional unit into value and scale.""" + text = str(token).strip().replace("µ", "u") + m = re.search(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*([a-zA-Z]*)", text) if m is None: - raise ValueError(f"Could not parse SamplingRate from {val!r}") - return float(m.group(0)) + raise ValueError(f"Could not parse numeric value from {token!r}") + num = float(m.group(1)) + unit = m.group(2).lower() + if unit_scale is None: + scale = 1.0 + else: + scale = unit_scale.get(unit, 1.0) + return num, scale def _parse_bci2k_header(fname): @@ -73,8 +80,18 @@ def _parse_bci2k_header(fname): if "Parameter Definition" in current_section: if "=" in line: left, right = line.split("=", 1) - name = left.strip().split()[-1] - value = right.strip().split()[0] + left_tokens = left.strip().split() + name = left_tokens[-1] + param_type = left_tokens[-2].lower() if len(left_tokens) >= 2 else "" + rhs = right.split("//", 1)[0].strip() + rhs_tokens = rhs.split() + if not rhs_tokens: + continue + if param_type.endswith("list"): + n_vals = int(rhs_tokens[0]) + value = rhs_tokens[1 : n_vals + 1] + else: + value = rhs_tokens[0] params[name] = value continue @@ -101,7 +118,10 @@ def _parse_bci2k_header(fname): "Could not find 'SamplingRate' in the BCI2000 Parameter Definition section." ) - sfreq = _parse_sampling_rate(params["SamplingRate"]) + sfreq_val, sfreq_scale = _parse_value_with_unit( + params["SamplingRate"], unit_scale=_FREQ_SCALE + ) + sfreq = sfreq_val * sfreq_scale return { "header_len": header_len, @@ -154,6 +174,21 @@ def _read_bci2k_data(fname, info_dict): ) signal = signal.T.astype(np.float64) # (n_channels, n_samples) + params = info_dict["params"] + if "SourceChOffset" in params and "SourceChGain" in params: + offsets = params["SourceChOffset"] + gains = params["SourceChGain"] + if len(offsets) != n_channels or len(gains) != n_channels: + raise ValueError( + "Expected SourceChOffset and SourceChGain lengths to match SourceCh." + ) + offsets_arr = np.array([_parse_value_with_unit(val)[0] for val in offsets]) + gain_parsed = [_parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains] + gains_arr = np.array([val for val, _ in gain_parsed]) + gain_scales = np.array([scale for _, scale in gain_parsed]) + signal = (signal + offsets_arr[:, np.newaxis]) * ( + gains_arr[:, np.newaxis] * gain_scales[:, np.newaxis] + ) state_bytes = state_bytes.T # (state_vec_len, n_samples), dtype=uint8 return signal, state_bytes diff --git a/mne/io/bci2k/tests/test_bci2k.py b/mne/io/bci2k/tests/test_bci2k.py index 3aad8661740..5eed13fd355 100644 --- a/mne/io/bci2k/tests/test_bci2k.py +++ b/mne/io/bci2k/tests/test_bci2k.py @@ -4,6 +4,10 @@ import mne from mne.datasets import testing +from mne.io.bci2k.bci2k import ( + _parse_bci2k_header, + _parse_value_with_unit, +) data_path = testing.data_path(download=False) bci2k_fname = data_path / "BCI2k" / "bci2k_test.dat" @@ -25,3 +29,21 @@ def test_read_raw_bci2k(): assert events.ndim == 2 assert events.shape[1] == 3 assert "RawBCI2k" in repr(raw) + + info_dict = _parse_bci2k_header(bci2k_fname) + assert info_dict["params"]["SourceChOffset"] == ["0", "0"] + assert info_dict["params"]["SourceChGain"] == ["0.1muV", "0.1muV"] + + +def test_parse_value_with_unit(): + """Test numeric token parsing with embedded unit suffixes.""" + volt_scale = {"v": 1.0, "mv": 1e-3, "muv": 1e-6, "uv": 1e-6, "nv": 1e-9} + assert _parse_value_with_unit("0.1muV", unit_scale=volt_scale) == (0.1, 1e-6) + assert _parse_value_with_unit("2mV", unit_scale=volt_scale) == (2.0, 1e-3) + assert _parse_value_with_unit("-3.5µV", unit_scale=volt_scale) == (-3.5, 1e-6) + + freq_scale = {"hz": 1.0, "khz": 1e3} + value, scale = _parse_value_with_unit("256Hz", unit_scale=freq_scale) + assert value * scale == 256 + value, scale = _parse_value_with_unit("0.5kHz", unit_scale=freq_scale) + assert value * scale == 500 From 376a7ba9a1ee1d92433df563ddc7a51581da5a8c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:21:32 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/io/bci2k/bci2k.py | 8 ++++++-- mne/io/bci2k/tests/test_bci2k.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/mne/io/bci2k/bci2k.py b/mne/io/bci2k/bci2k.py index 4d5805611f9..f6dd44467fa 100644 --- a/mne/io/bci2k/bci2k.py +++ b/mne/io/bci2k/bci2k.py @@ -82,7 +82,9 @@ def _parse_bci2k_header(fname): left, right = line.split("=", 1) left_tokens = left.strip().split() name = left_tokens[-1] - param_type = left_tokens[-2].lower() if len(left_tokens) >= 2 else "" + param_type = ( + left_tokens[-2].lower() if len(left_tokens) >= 2 else "" + ) rhs = right.split("//", 1)[0].strip() rhs_tokens = rhs.split() if not rhs_tokens: @@ -183,7 +185,9 @@ def _read_bci2k_data(fname, info_dict): "Expected SourceChOffset and SourceChGain lengths to match SourceCh." ) offsets_arr = np.array([_parse_value_with_unit(val)[0] for val in offsets]) - gain_parsed = [_parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains] + gain_parsed = [ + _parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains + ] gains_arr = np.array([val for val, _ in gain_parsed]) gain_scales = np.array([scale for _, scale in gain_parsed]) signal = (signal + offsets_arr[:, np.newaxis]) * ( diff --git a/mne/io/bci2k/tests/test_bci2k.py b/mne/io/bci2k/tests/test_bci2k.py index 5eed13fd355..44bf99c8c8c 100644 --- a/mne/io/bci2k/tests/test_bci2k.py +++ b/mne/io/bci2k/tests/test_bci2k.py @@ -29,7 +29,7 @@ def test_read_raw_bci2k(): assert events.ndim == 2 assert events.shape[1] == 3 assert "RawBCI2k" in repr(raw) - + info_dict = _parse_bci2k_header(bci2k_fname) assert info_dict["params"]["SourceChOffset"] == ["0", "0"] assert info_dict["params"]["SourceChGain"] == ["0.1muV", "0.1muV"] From 3442039ed6b0d471f002cb1a3b7dc279533b8fa1 Mon Sep 17 00:00:00 2001 From: Hansuja Date: Sun, 3 May 2026 14:43:03 +0530 Subject: [PATCH 3/7] simplify unit parser --- mne/io/bci2k/bci2k.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/mne/io/bci2k/bci2k.py b/mne/io/bci2k/bci2k.py index 4d5805611f9..e7356a70c74 100644 --- a/mne/io/bci2k/bci2k.py +++ b/mne/io/bci2k/bci2k.py @@ -11,23 +11,23 @@ from ...utils import verbose from ..base import BaseRaw -_VOLT_SCALE = {"v": 1.0, "mv": 1e-3, "muv": 1e-6, "uv": 1e-6, "nv": 1e-9} -_FREQ_SCALE = {"hz": 1.0, "khz": 1e3} +_VOLT_SCALE = {"v": 1.0, "mv": 1e-3, "muv": 1e-6, "uv": 1e-6, "nv": 1e-9, "": 1e-6} +_FREQ_SCALE = {"hz": 1.0, "khz": 1e3, "": 1.0} -def _parse_value_with_unit(token, unit_scale=None): +def _parse_value_with_unit(token, unit_scale): """Split a numeric token with optional unit into value and scale.""" text = str(token).strip().replace("µ", "u") - m = re.search(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*([a-zA-Z]*)", text) - if m is None: - raise ValueError(f"Could not parse numeric value from {token!r}") - num = float(m.group(1)) - unit = m.group(2).lower() - if unit_scale is None: - scale = 1.0 + m = re.search(r"[a-zA-Z]+$", text) + if m: + num = float(text[:m.start()]) + unit = m.group().lower() else: - scale = unit_scale.get(unit, 1.0) - return num, scale + num = float(text) + unit = "" + if unit not in unit_scale: + raise ValueError(f"Unrecognized unit '{unit}' in token '{token}'.") + return num, unit_scale[unit] def _parse_bci2k_header(fname): @@ -182,7 +182,7 @@ def _read_bci2k_data(fname, info_dict): raise ValueError( "Expected SourceChOffset and SourceChGain lengths to match SourceCh." ) - offsets_arr = np.array([_parse_value_with_unit(val)[0] for val in offsets]) + offsets_arr = np.array([float(val) for val in offsets]) gain_parsed = [_parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains] gains_arr = np.array([val for val, _ in gain_parsed]) gain_scales = np.array([scale for _, scale in gain_parsed]) From 682fef1a77046f0695f756c3475dd44fca89280b Mon Sep 17 00:00:00 2001 From: Hansuja Date: Sun, 3 May 2026 15:09:36 +0530 Subject: [PATCH 4/7] added changelog file --- doc/changes/dev/13869.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13869.bugfix.rst diff --git a/doc/changes/dev/13869.bugfix.rst b/doc/changes/dev/13869.bugfix.rst new file mode 100644 index 00000000000..77bdfdd1dd8 --- /dev/null +++ b/doc/changes/dev/13869.bugfix.rst @@ -0,0 +1 @@ +Fix SourceChGain/SourceChOffset calibration in BCI2000 reader so EEG data is returned in volts, by :newcontrib:`Hansuja Budhiraja`. \ No newline at end of file From e5a62a088edf11e1d66fbe009a0c7e5f53562704 Mon Sep 17 00:00:00 2001 From: Hansuja Date: Sun, 3 May 2026 15:10:48 +0530 Subject: [PATCH 5/7] fixed changelog file --- doc/changes/dev/13869.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/dev/13869.bugfix.rst b/doc/changes/dev/13869.bugfix.rst index 77bdfdd1dd8..259f7aaff79 100644 --- a/doc/changes/dev/13869.bugfix.rst +++ b/doc/changes/dev/13869.bugfix.rst @@ -1 +1 @@ -Fix SourceChGain/SourceChOffset calibration in BCI2000 reader so EEG data is returned in volts, by :newcontrib:`Hansuja Budhiraja`. \ No newline at end of file +Fix SourceChGain/SourceChOffset calibration in BCI2000 reader so EEG data is returned in volts, by `Hansuja Budhiraja`_. \ No newline at end of file From 682cf2b8795c66d35b8c24ba33aa15de7076b080 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 09:46:46 +0000 Subject: [PATCH 6/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/io/bci2k/bci2k.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mne/io/bci2k/bci2k.py b/mne/io/bci2k/bci2k.py index 530f867a7f4..d8bf5a488dc 100644 --- a/mne/io/bci2k/bci2k.py +++ b/mne/io/bci2k/bci2k.py @@ -20,7 +20,7 @@ def _parse_value_with_unit(token, unit_scale): text = str(token).strip().replace("µ", "u") m = re.search(r"[a-zA-Z]+$", text) if m: - num = float(text[:m.start()]) + num = float(text[: m.start()]) unit = m.group().lower() else: num = float(text) @@ -185,7 +185,9 @@ def _read_bci2k_data(fname, info_dict): "Expected SourceChOffset and SourceChGain lengths to match SourceCh." ) offsets_arr = np.array([_parse_value_with_unit(val)[0] for val in offsets]) - gain_parsed = [_parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains] + gain_parsed = [ + _parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains + ] gains_arr = np.array([val for val, _ in gain_parsed]) gain_scales = np.array([scale for _, scale in gain_parsed]) signal = (signal + offsets_arr[:, np.newaxis]) * ( From 6c05e3bf8e6aa28cb74f7abb689ce8a333023de3 Mon Sep 17 00:00:00 2001 From: Hansuja Date: Sun, 3 May 2026 17:54:04 +0530 Subject: [PATCH 7/7] fix offset error --- mne/io/bci2k/bci2k.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/bci2k/bci2k.py b/mne/io/bci2k/bci2k.py index 530f867a7f4..7b49b0dbd23 100644 --- a/mne/io/bci2k/bci2k.py +++ b/mne/io/bci2k/bci2k.py @@ -184,7 +184,7 @@ def _read_bci2k_data(fname, info_dict): raise ValueError( "Expected SourceChOffset and SourceChGain lengths to match SourceCh." ) - offsets_arr = np.array([_parse_value_with_unit(val)[0] for val in offsets]) + offsets_arr = np.array([float(val) for val in offsets]) gain_parsed = [_parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains] gains_arr = np.array([val for val, _ in gain_parsed]) gain_scales = np.array([scale for _, scale in gain_parsed])