Skip to content

Fix BCI2000RawIO scaling: decimal gains were parsed as 0 and the offset was applied with the wrong convention - #1932

Open
aaronkanzer wants to merge 1 commit into
NeuralEnsemble:masterfrom
aaronkanzer:fix/bci2000-scaling
Open

aaronkanzer wants to merge 1 commit into
NeuralEnsemble:masterfrom
aaronkanzer:fix/bci2000-scaling

Conversation

@aaronkanzer

Copy link
Copy Markdown

BCI2000RawIO has three problems that together make the scaled signals wrong for every channel.

  1. On NumPy 2.5, parse_header() raises TypeError: 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.
  2. SourceChGain values like 0.01617 are parsed as 0. Header values are matched with r"(-*\d+)(\w*)", which stops at the decimal point. The string fallback in _parse_header has the same problem.
  3. SourceChOffset is passed straight through as neo's offset. BCI2000 defines uV = (raw - SourceChOffset) * SourceChGain, while neo computes raw * gain + offset.

Repro with bci2000/eeg1_1.dat from ephy_testing_data. Its header has SourceChGain= 64 0.01617 0.01591 ... and SourceChOffset= 64 43 55 .... Run with neo 0.14.6 and NumPy 2.2 (on NumPy 2.5 it fails at parse_header()):

from neo.rawio.bci2000rawio import BCI2000RawIO
r = BCI2000RawIO(filename="eeg1_1.dat"); r.parse_header()
print(r.header["signal_channels"]["gain"][:3])
raw = r.get_analogsignal_chunk(0, 0, 0, 3, 0, channel_indexes=[0, 1, 2])
print(r.rescale_signal_raw_to_float(raw, dtype="float64", stream_index=0, channel_indexes=[0, 1, 2]))

Observed:

[0. 0. 0.]
[[43. 55. 69.]
 [43. 55. 69.]
 [43. 55. 69.]]

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 own FileReader.py (the BCI2kReader package) to within 1.5e-5 uV.

Fix:

  • Use "S64" instead of "a64".
  • Parse float header values and the gain and offset strings with a pattern that keeps the decimal part. Integer parameters keep the old pattern.
  • Store offset = -SourceChOffset * SourceChGain.

The rawio test class also defined entities_to_download twice and never set entities_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.

@zm711

zm711 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

@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 zm711 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great thanks for this. I'll keep working on the testing side, but just a couple tiny (cosmetic) changes.

Comment thread neo/rawio/bci2000rawio.py


# 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*)"

Copy link
Copy Markdown
Contributor

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

Comment thread neo/rawio/bci2000rawio.py
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)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.


entities_to_test = ["bci2000/eeg1_1.dat", "bci2000/eeg1_2.dat", "bci2000/eeg1_3.dat"]

def test_scaling(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me think about this test for a moment in general too.

@h-mayorquin h-mayorquin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

# Standard case: contains gate, trigger, device, and stream kind
# Example: Noise4Sam_g0_t0.imec0.ap
# Format: {run_name}_g{gate_num}_t{trigger_num}.{device}.{stream_kind}
# Regex tokens:
# \S+ → one or more non-whitespace characters
# \d+ → one or more digits
# ap|lf → either 'ap' or 'lf'
regex = r"(?P<run_name>\S+)_g(?P<gate_num>\d+)_t(?P<trigger_num>\d+)\.(?P<device>\S+)\.(?P<stream_kind>ap|lf)"

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 h-mayorquin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above should have come as request changes, correcting that now.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants