Skip to content

Commit 80e8d6b

Browse files
authored
Merge pull request #1932 from aaronkanzer/fix/bci2000-scaling
Fix BCI2000RawIO scaling: decimal gains were parsed as 0 and the offset was applied with the wrong convention
2 parents 1f2591d + edd797b commit 80e8d6b

2 files changed

Lines changed: 33 additions & 9 deletions

File tree

‎neo/rawio/bci2000rawio.py‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@
2424
)
2525
from neo.core import NeoReadWriteError
2626

27+
# A decimal number followed by an optional unit
28+
# Example: "0.01617", "-1.5e-3", "0.003muV", "256.0Hz"
29+
# Format: {number}{unit}
30+
# Regex tokens:
31+
# [-+]? → optional sign
32+
# \d+\.?\d* → digits with an optional decimal part, e.g. "43", "5.", "0.01617"
33+
# \.\d+ → or a number that starts at the decimal point, e.g. ".5"
34+
# [eE][-+]?\d+ → optional exponent, e.g. "e-3"
35+
# \w* → the unit, e.g. "muV", possibly empty
36+
_number_pattern = r"(?P<number>[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)(?P<unit>\w*)"
37+
2738

2839
class BCI2000RawIO(BaseRawIO):
2940
"""
@@ -74,16 +85,16 @@ def _parse_header(self):
7485
gain = param_defs["SourceChGain"]["value"][chan_ix]
7586

7687
if isinstance(gain, str):
77-
r = re.findall(r"(\d+)(\D+)", gain)
7888
# some files have strange units attached to gain
7989
# in that case it is ignored
80-
if len(r) == 1:
81-
gain = r[0][0]
82-
gain = float(gain)
90+
gain = float(re.match(_number_pattern, gain).group("number"))
8391

8492
offset = param_defs["SourceChOffset"]["value"][chan_ix]
8593
if isinstance(offset, str):
86-
offset = float(offset)
94+
offset = float(re.match(_number_pattern, offset).group("number"))
95+
# BCI2000 defines uV = (raw - SourceChOffset) * SourceChGain
96+
# while neo computes raw * gain + offset
97+
offset = -offset * gain
8798

8899
sig_channels.append((ch_name, chan_id, sr, dtype, units, gain, offset, stream_id, buffer_id))
89100
self.header["signal_channels"] = np.array(sig_channels, dtype=_signal_channel_dtype)
@@ -280,7 +291,8 @@ def rescale_value(param_val, data_type):
280291
if param_val.lower().startswith("0x"):
281292
param_val = int(param_val, 16)
282293
elif data_type in ["int", "float"]:
283-
matches = re.match(r"(-*\d+)(\w*)", param_val)
294+
pattern = r"(-*\d+)(\w*)" if data_type == "int" else _number_pattern
295+
matches = re.match(pattern, param_val)
284296
if matches is not None: # Can be None for % in def, min, max vals
285297
param_val, unit_str = matches.group(1), matches.group(2)
286298
param_val = int(param_val) if data_type == "int" else float(param_val)
@@ -325,7 +337,9 @@ def parse_dimensions(param_list):
325337
if temp != "[ State Vector Definition ]":
326338
raise NeoReadWriteError(f"State definitions not found in header {filename}")
327339
state_defs = []
328-
state_def_dtype = [("name", "a64"), ("length", int), ("startVal", int), ("bytePos", int), ("bitPos", int)]
340+
# "S64" because NumPy 2.0 deprecated the "a64" alias (https://github.com/numpy/numpy/pull/24854)
341+
# and NumPy 2.5 removed it, see https://github.com/NeuralEnsemble/python-neo/pull/1932
342+
state_def_dtype = [("name", "S64"), ("length", int), ("startVal", int), ("bytePos", int), ("bitPos", int)]
329343
while True:
330344
temp = fid.readline().decode("utf8").strip()
331345
if len(temp) == 0 or temp[0] == "[":

‎neo/test/rawiotest/test_bci2000rawio.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import unittest
66

7+
import numpy as np
8+
79
from neo.rawio.bci2000rawio import BCI2000RawIO
810
from neo.test.rawiotest.common_rawio_test import BaseTestRawIO
911

@@ -14,12 +16,20 @@ class TestBCI2000RawIO(
1416
):
1517
rawioclass = BCI2000RawIO
1618

17-
entities_to_download = ["bci2000/eeg1_1.dat", "bci2000/eeg1_2.dat", "bci2000/eeg1_3.dat"]
18-
1919
entities_to_download = [
2020
"bci2000",
2121
]
2222

23+
entities_to_test = ["bci2000/eeg1_1.dat", "bci2000/eeg1_2.dat", "bci2000/eeg1_3.dat"]
24+
25+
def test_scaling(self):
26+
reader = BCI2000RawIO(filename=self.get_local_path("bci2000/eeg1_1.dat"))
27+
reader.parse_header()
28+
raw = reader.get_analogsignal_chunk(0, 0, 0, 3, 0, channel_indexes=[0])
29+
uV = reader.rescale_signal_raw_to_float(raw, dtype="float64", stream_index=0, channel_indexes=[0])
30+
# first samples as decoded by BCI2000's own FileReader.py (BCI2kReader)
31+
np.testing.assert_allclose(uV[:, 0], [-16.218512, 1.3744501, -9.23307], rtol=1e-5)
32+
2333

2434
if __name__ == "__main__":
2535
unittest.main()

0 commit comments

Comments
 (0)