Repository navigation
Fix BCI2000RawIO scaling: decimal gains were parsed as 0 and the offset was applied with the wrong convention #1932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,16 +74,16 @@ def _parse_header(self): | |
| gain = param_defs["SourceChGain"]["value"][chan_ix] | ||
|
|
||
| if isinstance(gain, str): | ||
| r = re.findall(r"(\d+)(\D+)", gain) | ||
| # some files have strange units attached to gain | ||
| # in that case it is ignored | ||
| if len(r) == 1: | ||
| gain = r[0][0] | ||
| gain = float(gain) | ||
| gain = float(re.match(_number_pattern, gain).group(1)) | ||
|
|
||
| offset = param_defs["SourceChOffset"]["value"][chan_ix] | ||
| if isinstance(offset, str): | ||
| offset = float(offset) | ||
| offset = float(re.match(_number_pattern, offset).group(1)) | ||
| # BCI2000 defines uV = (raw - SourceChOffset) * SourceChGain | ||
| # while neo computes raw * gain + offset | ||
| offset = -offset * gain | ||
|
|
||
| sig_channels.append((ch_name, chan_id, sr, dtype, units, gain, offset, stream_id, buffer_id)) | ||
| self.header["signal_channels"] = np.array(sig_channels, dtype=_signal_channel_dtype) | ||
|
|
@@ -254,6 +254,10 @@ def _event_arrays_list(self): | |
| return self._my_events | ||
|
|
||
|
|
||
| # a decimal number such as "0.003" or "-1.5e-3" followed by optional units such as "muV" | ||
| _number_pattern = r"([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)(\w*)" | ||
|
|
||
|
|
||
| def parse_bci2000_header(filename): | ||
| # typically we want parameter values in Hz, seconds, or microvolts. | ||
| scales_dict = { | ||
|
|
@@ -280,7 +284,8 @@ def rescale_value(param_val, data_type): | |
| if param_val.lower().startswith("0x"): | ||
| param_val = int(param_val, 16) | ||
| elif data_type in ["int", "float"]: | ||
| matches = re.match(r"(-*\d+)(\w*)", param_val) | ||
| pattern = r"(-*\d+)(\w*)" if data_type == "int" else _number_pattern | ||
| matches = re.match(pattern, param_val) | ||
| if matches is not None: # Can be None for % in def, min, max vals | ||
| param_val, unit_str = matches.group(1), matches.group(2) | ||
| param_val = int(param_val) if data_type == "int" else float(param_val) | ||
|
|
@@ -325,7 +330,7 @@ def parse_dimensions(param_list): | |
| if temp != "[ State Vector Definition ]": | ||
| raise NeoReadWriteError(f"State definitions not found in header {filename}") | ||
| state_defs = [] | ||
| state_def_dtype = [("name", "a64"), ("length", int), ("startVal", int), ("bytePos", int), ("bitPos", int)] | ||
| state_def_dtype = [("name", "S64"), ("length", int), ("startVal", int), ("bytePos", int), ("bitPos", int)] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would also love a comment here just mentioning linking back to your PR mentioning the deprecation. If NumPy did it once they could deprecate these string arguments in the future and it is nice to have a quick reference to making these switches. |
||
| while True: | ||
| temp = fid.readline().decode("utf8").strip() | ||
| if len(temp) == 0 or temp[0] == "[": | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
|
|
||
| import unittest | ||
|
|
||
| import numpy as np | ||
|
|
||
| from neo.rawio.bci2000rawio import BCI2000RawIO | ||
| from neo.test.rawiotest.common_rawio_test import BaseTestRawIO | ||
|
|
||
|
|
@@ -14,12 +16,27 @@ class TestBCI2000RawIO( | |
| ): | ||
| rawioclass = BCI2000RawIO | ||
|
|
||
| entities_to_download = ["bci2000/eeg1_1.dat", "bci2000/eeg1_2.dat", "bci2000/eeg1_3.dat"] | ||
|
|
||
| entities_to_download = [ | ||
| "bci2000", | ||
| ] | ||
|
|
||
| entities_to_test = ["bci2000/eeg1_1.dat", "bci2000/eeg1_2.dat", "bci2000/eeg1_3.dat"] | ||
|
|
||
| def test_scaling(self): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me think about this test for a moment in general too. |
||
| # The header has SourceChGain= 64 0.01617 0.01591 ... and SourceChOffset= 64 43 55 ... | ||
| # BCI2000 defines uV = (raw - SourceChOffset) * SourceChGain | ||
| reader = BCI2000RawIO(filename=self.get_local_path("bci2000/eeg1_1.dat")) | ||
| reader.parse_header() | ||
| signal_channels = reader.header["signal_channels"] | ||
| np.testing.assert_allclose(signal_channels["gain"][:2], [0.01617, 0.01591], rtol=1e-6) | ||
|
|
||
| raw = reader.get_analogsignal_chunk(0, 0, 0, 3, 0, channel_indexes=[0, 1]) | ||
| uV = reader.rescale_signal_raw_to_float(raw, dtype="float64", stream_index=0, channel_indexes=[0, 1]) | ||
| expected = (raw.astype("float64") - [43, 55]) * [0.01617, 0.01591] | ||
| np.testing.assert_allclose(uV, expected, rtol=1e-5) | ||
| # first samples as decoded by BCI2000's own FileReader.py (BCI2kReader) | ||
| np.testing.assert_allclose(uV[:, 0], [-16.218512, 1.3744501, -9.23307], rtol=1e-5) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One tiny readability thing from my perspective. Could we move this to near the top of the file (like below imports) or near the bottom so it's easier to find in the future. Mid-file will just make it hard to find _number_pattern if I'm scanning the file without ctrl+f