Skip to content

Commit 97d6925

Browse files
committed
Fix SourceChGain/SourceChOffset handling in BCI2k reader
1 parent 4a78cb5 commit 97d6925

2 files changed

Lines changed: 68 additions & 11 deletions

File tree

mne/io/bci2k/bci2k.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,23 @@
1111
from ...utils import verbose
1212
from ..base import BaseRaw
1313

14+
_VOLT_SCALE = {"v": 1.0, "mv": 1e-3, "muv": 1e-6, "uv": 1e-6, "nv": 1e-9}
15+
_FREQ_SCALE = {"hz": 1.0, "khz": 1e3}
1416

15-
def _parse_sampling_rate(val):
16-
# Accept e.g. "256", "256Hz", "256.0 Hz"
17-
text = str(val).strip()
18-
text = re.sub(r"\s*Hz\s*$", "", text, flags=re.IGNORECASE)
19-
# Grab the first float-looking token
20-
m = re.search(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", text)
17+
18+
def _parse_value_with_unit(token, unit_scale=None):
19+
"""Split a numeric token with optional unit into value and scale."""
20+
text = str(token).strip().replace("µ", "u")
21+
m = re.search(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*([a-zA-Z]*)", text)
2122
if m is None:
22-
raise ValueError(f"Could not parse SamplingRate from {val!r}")
23-
return float(m.group(0))
23+
raise ValueError(f"Could not parse numeric value from {token!r}")
24+
num = float(m.group(1))
25+
unit = m.group(2).lower()
26+
if unit_scale is None:
27+
scale = 1.0
28+
else:
29+
scale = unit_scale.get(unit, 1.0)
30+
return num, scale
2431

2532

2633
def _parse_bci2k_header(fname):
@@ -73,8 +80,18 @@ def _parse_bci2k_header(fname):
7380
if "Parameter Definition" in current_section:
7481
if "=" in line:
7582
left, right = line.split("=", 1)
76-
name = left.strip().split()[-1]
77-
value = right.strip().split()[0]
83+
left_tokens = left.strip().split()
84+
name = left_tokens[-1]
85+
param_type = left_tokens[-2].lower() if len(left_tokens) >= 2 else ""
86+
rhs = right.split("//", 1)[0].strip()
87+
rhs_tokens = rhs.split()
88+
if not rhs_tokens:
89+
continue
90+
if param_type.endswith("list"):
91+
n_vals = int(rhs_tokens[0])
92+
value = rhs_tokens[1 : n_vals + 1]
93+
else:
94+
value = rhs_tokens[0]
7895
params[name] = value
7996
continue
8097

@@ -101,7 +118,10 @@ def _parse_bci2k_header(fname):
101118
"Could not find 'SamplingRate' in the BCI2000 Parameter Definition section."
102119
)
103120

104-
sfreq = _parse_sampling_rate(params["SamplingRate"])
121+
sfreq_val, sfreq_scale = _parse_value_with_unit(
122+
params["SamplingRate"], unit_scale=_FREQ_SCALE
123+
)
124+
sfreq = sfreq_val * sfreq_scale
105125

106126
return {
107127
"header_len": header_len,
@@ -154,6 +174,21 @@ def _read_bci2k_data(fname, info_dict):
154174
)
155175

156176
signal = signal.T.astype(np.float64) # (n_channels, n_samples)
177+
params = info_dict["params"]
178+
if "SourceChOffset" in params and "SourceChGain" in params:
179+
offsets = params["SourceChOffset"]
180+
gains = params["SourceChGain"]
181+
if len(offsets) != n_channels or len(gains) != n_channels:
182+
raise ValueError(
183+
"Expected SourceChOffset and SourceChGain lengths to match SourceCh."
184+
)
185+
offsets_arr = np.array([_parse_value_with_unit(val)[0] for val in offsets])
186+
gain_parsed = [_parse_value_with_unit(val, unit_scale=_VOLT_SCALE) for val in gains]
187+
gains_arr = np.array([val for val, _ in gain_parsed])
188+
gain_scales = np.array([scale for _, scale in gain_parsed])
189+
signal = (signal + offsets_arr[:, np.newaxis]) * (
190+
gains_arr[:, np.newaxis] * gain_scales[:, np.newaxis]
191+
)
157192
state_bytes = state_bytes.T # (state_vec_len, n_samples), dtype=uint8
158193

159194
return signal, state_bytes

mne/io/bci2k/tests/test_bci2k.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
import mne
66
from mne.datasets import testing
7+
from mne.io.bci2k.bci2k import (
8+
_parse_bci2k_header,
9+
_parse_value_with_unit,
10+
)
711

812
data_path = testing.data_path(download=False)
913
bci2k_fname = data_path / "BCI2k" / "bci2k_test.dat"
@@ -25,3 +29,21 @@ def test_read_raw_bci2k():
2529
assert events.ndim == 2
2630
assert events.shape[1] == 3
2731
assert "RawBCI2k" in repr(raw)
32+
33+
info_dict = _parse_bci2k_header(bci2k_fname)
34+
assert info_dict["params"]["SourceChOffset"] == ["0", "0"]
35+
assert info_dict["params"]["SourceChGain"] == ["0.1muV", "0.1muV"]
36+
37+
38+
def test_parse_value_with_unit():
39+
"""Test numeric token parsing with embedded unit suffixes."""
40+
volt_scale = {"v": 1.0, "mv": 1e-3, "muv": 1e-6, "uv": 1e-6, "nv": 1e-9}
41+
assert _parse_value_with_unit("0.1muV", unit_scale=volt_scale) == (0.1, 1e-6)
42+
assert _parse_value_with_unit("2mV", unit_scale=volt_scale) == (2.0, 1e-3)
43+
assert _parse_value_with_unit("-3.5µV", unit_scale=volt_scale) == (-3.5, 1e-6)
44+
45+
freq_scale = {"hz": 1.0, "khz": 1e3}
46+
value, scale = _parse_value_with_unit("256Hz", unit_scale=freq_scale)
47+
assert value * scale == 256
48+
value, scale = _parse_value_with_unit("0.5kHz", unit_scale=freq_scale)
49+
assert value * scale == 500

0 commit comments

Comments
 (0)