Repository navigation
Fix BCI2000RawIO scaling: decimal gains were parsed as 0 and the offset was applied with the wrong convention - #1932
aaronkanzer wants to merge 1 commit into
Conversation
|
@h-mayorquin could you review this? I don't think about regex at all so I trust your review of this. As far as NumPy 2.5 we haven't officially started support that yet. I'm working on that now. |
zm711
left a comment
There was a problem hiding this comment.
This is great thanks for this. I'll keep working on the testing side, but just a couple tiny (cosmetic) changes.
|
|
||
|
|
||
| # 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*)" |
There was a problem hiding this comment.
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
| 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)] |
There was a problem hiding this comment.
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.
|
|
||
| entities_to_test = ["bci2000/eeg1_1.dat", "bci2000/eeg1_2.dat", "bci2000/eeg1_3.dat"] | ||
|
|
||
| def test_scaling(self): |
There was a problem hiding this comment.
Let me think about this test for a moment in general too.
h-mayorquin
left a comment
There was a problem hiding this comment.
The fix looks good to me. BCI2000 defines the scaled value as (raw - SourceChOffset) * SourceChGain and neo computes raw * gain + offset so the offset has to be -SourceChOffset * SourceChGain as in this PR:
https://www.bci2000.org/mediawiki/index.php/User_Reference:DataIOFilter
@aaronkanzer I have one request, can you reduce test_scaling to the comparison with the values from BCI2kReader? That check alone fails for both the gain and the offset bugs. The other two assertions only restate the header values and the formula in the fix. Something like:
def test_scaling(self):
reader = BCI2000RawIO(filename=self.get_local_path("bci2000/eeg1_1.dat"))
reader.parse_header()
raw = reader.get_analogsignal_chunk(0, 0, 0, 3, 0, channel_indexes=[0])
uV = reader.rescale_signal_raw_to_float(raw, dtype="float64", stream_index=0, channel_indexes=[0])
# 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)The regex is correct but I would suggest improving it. In the spikeglx reader each regex comes with an example, the format and what each token matches:
python-neo/neo/rawio/spikeglxrawio.py
Lines 539 to 546 in 442658a
Something like this here:
# A decimal number followed by an optional unit
# Example: "0.01617", "-1.5e-3", "0.003muV", "256.0Hz"
# Format: {number}{unit}
# Regex tokens:
# [-+]? → optional sign
# \d+\.?\d* → digits with an optional decimal part, e.g. "43", "5.", "0.01617"
# \.\d+ → or a number that starts at the decimal point, e.g. ".5"
# [eE][-+]?\d+ → optional exponent, e.g. "e-3"
# \w* → the unit, e.g. "muV", possibly empty
_number_pattern = r"(?P<number>[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)(?P<unit>\w*)"Another option is to avoid the regex for the floats. The float values are a number followed by a unit made of letters and the only non-numeric values that BCI2000 writes are % (an empty value) and auto. So we could strip the unit and let float handle the sign, the decimals and the exponent:
elif data_type == "float" and param_val not in ("%", "auto"):
number = param_val.rstrip(string.ascii_letters)
unit_str = param_val[len(number):]
param_val = float(number) * scales_dict.get(unit_str.lower(), 1)And the same rstrip for the gain and the offset in _parse_header.
Up to you, use whatever you think is better.
While reading this PR I noticed that BCI2000RawIO is not exported from neo.rawio and I opened #1939 to fix it. That one should be merged after this one.
h-mayorquin
left a comment
There was a problem hiding this comment.
The above should have come as request changes, correcting that now.
BCI2000RawIOhas three problems that together make the scaled signals wrong for every channel.parse_header()raisesTypeError: data type 'a64' not understood. The state definitions use the"a64"dtype alias, which was deprecated in NumPy 2.0 and removed in 2.5.SourceChGainvalues like0.01617are parsed as0. Header values are matched withr"(-*\d+)(\w*)", which stops at the decimal point. The string fallback in_parse_headerhas the same problem.SourceChOffsetis passed straight through as neo'soffset. BCI2000 definesuV = (raw - SourceChOffset) * SourceChGain, while neo computesraw * gain + offset.Repro with
bci2000/eeg1_1.datfrom ephy_testing_data. Its header hasSourceChGain= 64 0.01617 0.01591 ...andSourceChOffset= 64 43 55 .... Run with neo 0.14.6 and NumPy 2.2 (on NumPy 2.5 it fails atparse_header()):Observed:
Every channel is a constant equal to its raw offset.
Expected: gains
[0.01617 0.01591 0.01595], and channel 0 starting[-16.2185, 1.3745, -9.2331]uV. With the fix, the whole file matches BCI2000's ownFileReader.py(theBCI2kReaderpackage) to within 1.5e-5 uV.Fix:
"S64"instead of"a64".offset = -SourceChOffset * SourceChGain.The rawio test class also defined
entities_to_downloadtwice and never setentities_to_test, so the compliance tests never ran on these files. The test now lists the three files and adds a scaling check against values from BCI2kReader.