From c65b081ca96f9befbd04d61b0c06fede0aea2be4 Mon Sep 17 00:00:00 2001 From: vivanov Date: Thu, 12 Nov 2026 02:36:46 +0100 Subject: [PATCH 1/2] fix: gate `has_spikegadgets_neuropixels_probes` on supported variants NRIC (Pixie384) recordings carry a SourceOptions block named "NeuroPixels1" just like real Neuropixels files, so the source-name scan alone returned True for them, and callers (e.g. SpikeInterface's `read_spikegadgets`) then hit the unsupported-variant ValueError in `read_spikegadgets_neuropixels`. Hoist the (device, deviceSubType) dispatch table and its normalization from `read_spikegadgets_neuropixels` to module level (`_SPIKEGADGETS_NEUROPIXELS_FORMATS`, `_spikegadgets_neuropixels_dispatch_key`) and require the dispatch key to be a supported variant in the gate, so the gate and the reader cannot drift apart. Add a Pixie384 header-only fixture and a negative test (guarded against vacuous passes on missing fixtures). --- src/probeinterface/neuropixels_tools.py | 135 +- ...est_data_Pixie384_20261104_header_only.rec | 1210 +++++++++++++++++ tests/test_io/test_spikegadgets.py | 11 + 3 files changed, 1295 insertions(+), 61 deletions(-) create mode 100644 tests/data/spikegadgets/SpikeGadgets_test_data_Pixie384_20261104_header_only.rec diff --git a/src/probeinterface/neuropixels_tools.py b/src/probeinterface/neuropixels_tools.py index aa35e5b0..144efa6e 100644 --- a/src/probeinterface/neuropixels_tools.py +++ b/src/probeinterface/neuropixels_tools.py @@ -1835,6 +1835,55 @@ def _spikegadgets_channel_index_np2_1shank(channel_index: int) -> int: return row * COLS_PER_SHANK + col_on_shank +# Dispatch keyed by SpikeConfiguration (device, deviceSubType) attributes +# (see Trodes `configuration.cpp:2495-2520` and `5246-5291`). Each entry +# gives the HardwareConfiguration `Device` name to filter on, the catalogue +# part number to build the full probe from, the per-probe horizontal shift +# (um) used when plotting multi-probe ProbeGroups, and (optionally) a +# function remapping Trodes' ``channelsOn`` bit position (channel_index, equal to +# ``electrode_id[1:] - 1`` in the .rec XML) to a probeinterface catalogue +# contact index. The remap is None when Trodes' ordering already matches +# the catalogue's (NP1.0 standard). +# +# All NP1.0 staggered catalogue variants (NP1000, NP1001, NP1010-NP1014, +# PRB_1_2_0480_2, PRB_1_4_0480_1, PRB_1_4_0480_1_C) share identical 2D +# geometry, so NP1000 is the canonical pick. All NP2.0 4-shank catalogue +# variants (NP2010, NP2013, NP2014, NP2020, NP2021) share identical 2D +# geometry, so NP2014 is the canonical pick. model_name and description +# are cleared on the sliced probe in both cases because the XML does not +# carry a part-number field. +_SPIKEGADGETS_NEUROPIXELS_FORMATS = { + ("neuropixels1", "10"): { + "hardware_device_name": "NeuroPixels1", + "part_number": "NP1000", + "multi_probe_plot_offset_um": 250.0, + "channel_index_to_catalogue_index": None, + }, + ("neuropixels2", "1_SHANK"): { + "hardware_device_name": "NeuroPixels2", + "part_number": "NP2000", + "multi_probe_plot_offset_um": 250.0, + "channel_index_to_catalogue_index": _spikegadgets_channel_index_np2_1shank, + }, + ("neuropixels2", "4_SHANK"): { + "hardware_device_name": "NeuroPixels2", + "part_number": "NP2014", + "multi_probe_plot_offset_um": 1000.0, + "channel_index_to_catalogue_index": _spikegadgets_channel_index_np2_4shank, + }, +} + + +def _spikegadgets_neuropixels_dispatch_key(sconf) -> tuple[str, str]: + # Older NP1.0 recordings predate the device/deviceSubType attributes, so + # missing values fall back to NP1.0 standard. + sconf_device = (sconf.attrib.get("device", "") if sconf is not None else "").lower() or "neuropixels1" + sconf_subtype = sconf.attrib.get("deviceSubType", "") if sconf is not None else "" + if sconf_device == "neuropixels1" and not sconf_subtype: + sconf_subtype = "10" + return (sconf_device, sconf_subtype) + + def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> ProbeGroup: """ Find active channels of the given Neuropixels probe from a SpikeGadgets .rec file. @@ -1863,63 +1912,19 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> probe_group : ProbeGroup object """ - # Dispatch keyed by SpikeConfiguration (device, deviceSubType) attributes - # (see Trodes `configuration.cpp:2495-2520` and `5246-5291`). Each entry - # gives the HardwareConfiguration `Device` name to filter on, the catalogue - # part number to build the full probe from, the per-probe horizontal shift - # (um) used when plotting multi-probe ProbeGroups, and (optionally) a - # function remapping Trodes' ``channelsOn`` bit position (channel_index, equal to - # ``electrode_id[1:] - 1`` in the .rec XML) to a probeinterface catalogue - # contact index. The remap is None when Trodes' ordering already matches - # the catalogue's (NP1.0 standard). - # - # All NP1.0 staggered catalogue variants (NP1000, NP1001, NP1010-NP1014, - # PRB_1_2_0480_2, PRB_1_4_0480_1, PRB_1_4_0480_1_C) share identical 2D - # geometry, so NP1000 is the canonical pick. All NP2.0 4-shank catalogue - # variants (NP2010, NP2013, NP2014, NP2020, NP2021) share identical 2D - # geometry, so NP2014 is the canonical pick. model_name and description - # are cleared on the sliced probe in both cases because the XML does not - # carry a part-number field. - spikegadgets_neuropixels_formats = { - ("neuropixels1", "10"): { - "hardware_device_name": "NeuroPixels1", - "part_number": "NP1000", - "multi_probe_plot_offset_um": 250.0, - "channel_index_to_catalogue_index": None, - }, - ("neuropixels2", "1_SHANK"): { - "hardware_device_name": "NeuroPixels2", - "part_number": "NP2000", - "multi_probe_plot_offset_um": 250.0, - "channel_index_to_catalogue_index": _spikegadgets_channel_index_np2_1shank, - }, - ("neuropixels2", "4_SHANK"): { - "hardware_device_name": "NeuroPixels2", - "part_number": "NP2014", - "multi_probe_plot_offset_um": 1000.0, - "channel_index_to_catalogue_index": _spikegadgets_channel_index_np2_4shank, - }, - } - header_txt = parse_spikegadgets_header(file) root = ElementTree.fromstring(header_txt) hconf = root.find("HardwareConfiguration") sconf = root.find("SpikeConfiguration") - # Older NP1.0 recordings predate the device/deviceSubType attributes, so - # missing values fall back to NP1.0 standard. - sconf_device = (sconf.attrib.get("device", "") if sconf is not None else "").lower() or "neuropixels1" - sconf_subtype = sconf.attrib.get("deviceSubType", "") if sconf is not None else "" - if sconf_device == "neuropixels1" and not sconf_subtype: - sconf_subtype = "10" - dispatch_key = (sconf_device, sconf_subtype) - if dispatch_key not in spikegadgets_neuropixels_formats: + dispatch_key = _spikegadgets_neuropixels_dispatch_key(sconf) + if dispatch_key not in _SPIKEGADGETS_NEUROPIXELS_FORMATS: raise ValueError( - f"Unsupported SpikeGadgets Neuropixels variant device={sconf_device!r} " - f"deviceSubType={sconf_subtype!r}; supported: " - f"{sorted(spikegadgets_neuropixels_formats)}" + f"Unsupported SpikeGadgets Neuropixels variant device={dispatch_key[0]!r} " + f"deviceSubType={dispatch_key[1]!r}; supported: " + f"{sorted(_SPIKEGADGETS_NEUROPIXELS_FORMATS)}" ) - fmt = spikegadgets_neuropixels_formats[dispatch_key] + fmt = _SPIKEGADGETS_NEUROPIXELS_FORMATS[dispatch_key] probe_configs = [d for d in hconf if d.attrib.get("name") == fmt["hardware_device_name"]] n_probes = len(probe_configs) @@ -2049,14 +2054,21 @@ def read_spikegadgets(*args, **kwargs) -> ProbeGroup: def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: """ Return True if the SpikeGadgets ``.rec`` file describes at least one - Neuropixels probe. + Neuropixels probe readable by :func:`read_spikegadgets_neuropixels`. + + Detection requires two conditions on the ``.rec`` XML header: - Detection scans the ``HardwareConfiguration`` block of the ``.rec`` XML - header for ``Device`` entries whose ``name`` attribute matches a known - Neuropixels source name (``"NeuroPixels1"`` or ``"NeuroPixels2"``). The - presence of any such entry is the ground-truth signal that the file - contains Neuropixels probe geometry, independent of what other hardware - the headstage is also streaming. + 1. The ``HardwareConfiguration`` block contains an entry (``Device`` or + ``SourceOptions``) whose ``name`` attribute matches a known Neuropixels + source name (``"NeuroPixels1"`` or ``"NeuroPixels2"``). + 2. The ``SpikeConfiguration`` ``(device, deviceSubType)`` pair is a + variant that :func:`read_spikegadgets_neuropixels` supports. + + Condition 2 matters because other headstages, such as NRIC (Pixie384) + recordings, also carry Neuropixels-named ``SourceOptions`` gain blocks + although their geometry is not Neuropixels; without it, callers gating + on this helper would trigger an unsupported-variant ``ValueError`` in the + reader. Intended use: callers that route heterogeneous SpikeGadgets recordings (mixing tetrodes, Neuropixels, etc.) can gate the call to @@ -2084,10 +2096,11 @@ def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: if hconf is None: return False - for device in hconf: - if device.attrib.get("name") in neuropixels_source_names: - return True - return False + if not any(child.attrib.get("name") in neuropixels_source_names for child in hconf): + return False + + sconf = root.find("SpikeConfiguration") + return _spikegadgets_neuropixels_dispatch_key(sconf) in _SPIKEGADGETS_NEUROPIXELS_FORMATS def parse_spikegadgets_header(file: str | Path) -> str: diff --git a/tests/data/spikegadgets/SpikeGadgets_test_data_Pixie384_20261104_header_only.rec b/tests/data/spikegadgets/SpikeGadgets_test_data_Pixie384_20261104_header_only.rec new file mode 100644 index 00000000..cd4230ec --- /dev/null +++ b/tests/data/spikegadgets/SpikeGadgets_test_data_Pixie384_20261104_header_only.rec @@ -0,0 +1,1210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py index 890d3f42..0190ee7e 100644 --- a/tests/test_io/test_spikegadgets.py +++ b/tests/test_io/test_spikegadgets.py @@ -14,6 +14,7 @@ data_path = Path(__file__).absolute().parent.parent / "data" / "spikegadgets" test_file = "SpikeGadgets_test_data_2xNpix1.0_20240318_173658_header_only.rec" test_file_np2_4shank = "SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec" +test_file_nric = "SpikeGadgets_test_data_Pixie384_20261104_header_only.rec" def test_parse_meta(): @@ -139,6 +140,15 @@ def test_has_spikegadgets_neuropixels_probes_missing_file(): assert has_spikegadgets_neuropixels_probes(data_path / "does_not_exist.rec") is False +def test_has_spikegadgets_neuropixels_probes_nric_negative(): + # NRIC (Pixie384) headers carry a gain-options + # block like real Neuropixels recordings, but their SpikeConfiguration device + # variant is not supported by read_spikegadgets_neuropixels: the gate must + # not fire for them. The exists() guard prevents a vacuous pass: the gate + # also returns False for nonexistent files. + assert has_spikegadgets_neuropixels_probes(data_path / test_file_nric) is False + + if __name__ == "__main__": test_parse_meta() test_neuropixels_1_reader() @@ -149,3 +159,4 @@ def test_has_spikegadgets_neuropixels_probes_missing_file(): test_read_spikegadgets_deprecation_warning() test_has_spikegadgets_neuropixels_probes_positive() test_has_spikegadgets_neuropixels_probes_missing_file() + test_has_spikegadgets_neuropixels_probes_nric_negative() From 5eb4b39f84232b0a47028b922b1c139dacd937fb Mon Sep 17 00:00:00 2001 From: vivanov Date: Thu, 12 Nov 2026 11:33:47 +0100 Subject: [PATCH 2/2] remove non-existent code reference in comments --- tests/test_io/test_spikegadgets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py index 0190ee7e..3f23716a 100644 --- a/tests/test_io/test_spikegadgets.py +++ b/tests/test_io/test_spikegadgets.py @@ -144,8 +144,7 @@ def test_has_spikegadgets_neuropixels_probes_nric_negative(): # NRIC (Pixie384) headers carry a gain-options # block like real Neuropixels recordings, but their SpikeConfiguration device # variant is not supported by read_spikegadgets_neuropixels: the gate must - # not fire for them. The exists() guard prevents a vacuous pass: the gate - # also returns False for nonexistent files. + # not fire for them. assert has_spikegadgets_neuropixels_probes(data_path / test_file_nric) is False