From 1b8e0d01dfeea933d7823f9d7cf44dd5ba7b09d9 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 21 May 2026 20:53:56 -0600 Subject: [PATCH 1/9] rename spikegadgets --- src/probeinterface/__init__.py | 2 + src/probeinterface/io.py | 68 +++++++++++++++++++++++++++++- tests/test_io/test_spikegadgets.py | 29 ++++++++++++- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/probeinterface/__init__.py b/src/probeinterface/__init__.py index 50b7dc12..45e102bf 100644 --- a/src/probeinterface/__init__.py +++ b/src/probeinterface/__init__.py @@ -15,6 +15,8 @@ read_BIDS_probe, write_BIDS_probe, read_spikegadgets, + read_spikegadgets_neuropixels, + has_spikegadgets_neuropixels_probes, read_mearec, read_nwb, read_maxwell, diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index ad1a60ae..25d4d82a 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -732,12 +732,17 @@ def write_csv(file, probe): raise NotImplementedError -def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: +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. SpikeGadgets headstages support up to three Neuropixels 1.0 probes (as of March 28, 2024), and information for all probes will be returned in a ProbeGroup object. + This function only supports Neuropixels probes recorded with SpikeGadgets + headstages (``HardwareConfiguration`` entries with ``name == "NeuroPixels1"``). + It does not handle tetrodes or other probe types that SpikeGadgets can + record. Use :func:`has_spikegadgets_neuropixels_probes` to check whether a + ``.rec`` file contains Neuropixels probe geometry before calling this reader. Parameters ---------- @@ -894,6 +899,67 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: return probe_group +def read_spikegadgets(*args, **kwargs) -> ProbeGroup: + """ + Deprecated alias for :func:`read_spikegadgets_neuropixels`. + + The name ``read_spikegadgets`` is misleading because the function only reads + Neuropixels probe geometry, not arbitrary SpikeGadgets ``.rec`` recordings. + Use :func:`read_spikegadgets_neuropixels` instead, and + :func:`has_spikegadgets_neuropixels_probes` to check whether a ``.rec`` file + has Neuropixels geometry before calling it. + """ + warnings.warn( + "read_spikegadgets is deprecated and will be removed in a future release. " + "Use read_spikegadgets_neuropixels instead.", + category=DeprecationWarning, + stacklevel=2, + ) + return read_spikegadgets_neuropixels(*args, **kwargs) + + +def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: + """ + Return True if the SpikeGadgets ``.rec`` file describes at least one + Neuropixels probe. + + Detection scans the ``HardwareConfiguration`` block of the ``.rec`` XML + header for ``Device`` entries whose ``name`` attribute matches a known + Neuropixels source name (currently ``"NeuroPixels1"``). 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. + + Intended use: callers that route heterogeneous SpikeGadgets recordings + (mixing tetrodes, Neuropixels, etc.) can gate the call to + :func:`read_spikegadgets_neuropixels` on this helper and skip probe + attachment for non-Neuropixels recordings. + + Parameters + ---------- + file : str or Path + Path to the SpikeGadgets ``.rec`` file. + + Returns + ------- + bool + """ + try: + header_txt = parse_spikegadgets_header(file) + root = ElementTree.fromstring(header_txt) + except Exception: + return False + + hconf = root.find("HardwareConfiguration") + if hconf is None: + return False + + for device in hconf: + if device.attrib.get("name") == "NeuroPixels1": + return True + return False + + def parse_spikegadgets_header(file: str | Path) -> str: """ Parse file (SpikeGadgets .rec format) into a string until "", diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py index 99770a35..1d430ad9 100644 --- a/tests/test_io/test_spikegadgets.py +++ b/tests/test_io/test_spikegadgets.py @@ -1,7 +1,13 @@ from pathlib import Path from xml.etree import ElementTree -from probeinterface import read_spikegadgets +import pytest + +from probeinterface import ( + read_spikegadgets, + read_spikegadgets_neuropixels, + has_spikegadgets_neuropixels_probes, +) from probeinterface.io import parse_spikegadgets_header from probeinterface.testing import validate_probe_dict @@ -18,7 +24,7 @@ def test_parse_meta(): def test_neuropixels_1_reader(): - probe_group = read_spikegadgets(data_path / test_file, raise_error=False) + probe_group = read_spikegadgets_neuropixels(data_path / test_file, raise_error=False) assert len(probe_group.probes) == 2 for probe in probe_group.probes: probe_dict = probe.to_dict(array_as_list=True) @@ -29,6 +35,25 @@ def test_neuropixels_1_reader(): assert probe_group.get_contact_count() == 768 +def test_read_spikegadgets_deprecation_warning(): + # Old read_spikegadgets name must still work but emit DeprecationWarning pointing at the new name. + with pytest.warns(DeprecationWarning, match="read_spikegadgets_neuropixels"): + read_spikegadgets(data_path / test_file, raise_error=False) + + +def test_has_spikegadgets_neuropixels_probes_positive(): + # A real Neuropixels .rec header should report True. + assert has_spikegadgets_neuropixels_probes(data_path / test_file) is True + + +def test_has_spikegadgets_neuropixels_probes_missing_file(): + # Unreadable / nonexistent files return False rather than raising. + assert has_spikegadgets_neuropixels_probes(data_path / "does_not_exist.rec") is False + + if __name__ == "__main__": test_parse_meta() test_neuropixels_1_reader() + test_read_spikegadgets_deprecation_warning() + test_has_spikegadgets_neuropixels_probes_positive() + test_has_spikegadgets_neuropixels_probes_missing_file() From 47bc6089d4c287d6856c34604128dc3ab632d930 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 22 May 2026 08:51:41 -0600 Subject: [PATCH 2/9] spikegadgets 2.0 --- src/probeinterface/io.py | 159 ++++++++++++++++++++++------- tests/test_io/test_spikegadgets.py | 57 +++++++++++ 2 files changed, 178 insertions(+), 38 deletions(-) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 90849e81..3bc07120 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -733,17 +733,79 @@ def write_csv(file, probe): raise NotImplementedError +def _spikegadgets_chind_identity(chind: int) -> int: + """``channelsOn`` bit position is the catalogue contact index directly. + + Used for NP1.0 standard: Trodes' ``channelsOn`` bitmask and the catalogue + ``NP1000`` share the same per-shank row-major ordering, so no remap is + needed. + """ + return chind + + +def _spikegadgets_chind_np2_4shank(chind: int) -> int: + """Remap NP2.0 4-shank ``channelsOn`` bit position to catalogue index. + + Trodes writes ``channelsOn`` row-major across all four shanks (eight + contacts per row, two columns per shank), with the column-within-row + direction reversed relative to ``probeColumn`` (high ``chind`` -> low + ``probeColumn``; see Trodes `configuration.cpp:5374-5421` and the + `probeColumn` annotations in the .rec ``SpikeChannel`` elements). The + catalogue (``NP2014``) is shank-major instead (s0e0..s0e1279, + s1e0..s1e1279, ...), so chind needs remapping. Verified empirically + against the SpikeGadgets-provided NP2.0 4-shank fixture: chind 1671 with + ``probeColumn="0"`` maps to ``s0e416``, chind 1664 with ``probeColumn="7"`` + maps to ``s3e417``, and the recovered ml/dv values match the catalogue + positions up to a single stereotactic offset. + """ + CONTACTS_PER_ROW = 8 # 2 columns per shank * 4 shanks + COLS_PER_SHANK = 2 + CONTACTS_PER_SHANK = 1280 + + row = chind // CONTACTS_PER_ROW + col_global = (CONTACTS_PER_ROW - 1) - (chind % CONTACTS_PER_ROW) + shank = col_global // COLS_PER_SHANK + col_on_shank = col_global % COLS_PER_SHANK + return shank * CONTACTS_PER_SHANK + row * COLS_PER_SHANK + col_on_shank + + +# Dispatch for `read_spikegadgets_neuropixels`, 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 the function that maps a Trodes ``channelsOn`` bit position (chind, equal +# to ``electrode_id[1:] - 1`` in the .rec XML) to a probeinterface catalogue +# contact index. +# +# 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 = { + # (device, deviceSubType): (HardwareConfiguration device name, part_number, multi_probe_x_shift_um, chind_to_catalogue_index) + ("neuropixels1", "10"): ("NeuroPixels1", "NP1000", 250.0, _spikegadgets_chind_identity), + ("neuropixels2", "4_SHANK"): ("NeuroPixels2", "NP2014", 1000.0, _spikegadgets_chind_np2_4shank), +} + + 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. - SpikeGadgets headstages support up to three Neuropixels 1.0 probes (as of March 28, 2024), + SpikeGadgets headstages support up to three Neuropixels probes simultaneously, and information for all probes will be returned in a ProbeGroup object. - This function only supports Neuropixels probes recorded with SpikeGadgets - headstages (``HardwareConfiguration`` entries with ``name == "NeuroPixels1"``). - It does not handle tetrodes or other probe types that SpikeGadgets can - record. Use :func:`has_spikegadgets_neuropixels_probes` to check whether a - ``.rec`` file contains Neuropixels probe geometry before calling this reader. + Supported Neuropixels variants: NP1.0 standard (``device="neuropixels1"``, + older recordings without ``deviceSubType`` are treated as standard) and + NP2.0 4-shank (``device="neuropixels2" deviceSubType="4_SHANK"``). + Other Neuropixels variants Trodes can describe (NP1.0 HD/NHP, NP2.0 single-shank, + NRIC) raise ``ValueError`` for now; non-Neuropixels probes (tetrodes etc.) are + not handled at all. Use :func:`has_spikegadgets_neuropixels_probes` to check + whether a ``.rec`` file contains Neuropixels probe geometry before calling + this reader. Parameters ---------- @@ -755,56 +817,74 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> probe_group : ProbeGroup object """ - # The SpikeGadgets .rec XML does not include a probe part number. The NP1.0 - # catalogue variants (NP1000, NP1001, PRB_1_2_0480_2, PRB_1_4_0480_1, - # PRB_1_4_0480_1_C) share identical 2D geometry in the probeinterface - # catalogue (contact positions, pitch, stagger, shank width), differing only - # in metadata that probeinterface does not consume (ADC resolution, databus - # phase, gain, on-shank reference, shank thickness). So hardcoding NP1000 - # produces correct geometry; `model_name` and `description` are cleared on - # the sliced probe to avoid claiming a specific variant. - PART_NUMBER = "NP1000" - header_txt = parse_spikegadgets_header(file) root = ElementTree.fromstring(header_txt) hconf = root.find("HardwareConfiguration") sconf = root.find("SpikeConfiguration") - probe_configs = [d for d in hconf if d.attrib.get("name") == "NeuroPixels1"] + # 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: + raise ValueError( + f"Unsupported SpikeGadgets Neuropixels variant device={sconf_device!r} " + f"deviceSubType={sconf_subtype!r}; supported: " + f"{sorted(_SPIKEGADGETS_NEUROPIXELS_FORMATS)}" + ) + ( + hconf_device_name, + part_number, + multi_probe_x_shift_um, + chind_to_catalogue_index, + ) = _SPIKEGADGETS_NEUROPIXELS_FORMATS[dispatch_key] + + probe_configs = [d for d in hconf if d.attrib.get("name") == hconf_device_name] n_probes = len(probe_configs) if n_probes == 0: if raise_error: - raise Exception("No Neuropixels 1.0 probes found") + raise Exception(f"No {hconf_device_name} probes found") return None - # NeuroPixels1 SourceOptions blocks carry the per-probe AP/LF gain settings. - # They appear in the same order as the SpikeNTrode probe digits (1, 2, 3). - source_options_blocks = [s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == "NeuroPixels1"] + # SourceOptions blocks carry the per-probe AP/LF gain settings. They appear + # in the same order as the SpikeNTrode probe digits (1, 2, 3). + source_options_blocks = [s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == hconf_device_name] probe_group = ProbeGroup() for curr_probe in range(1, n_probes + 1): # SpikeNTrode elements are the authoritative list of recorded electrodes. - # Each id is "<1-based electrode number>" for up to 960 - # electrodes on NP1.0; the catalogue uses 0-based indices, so - # catalogue_index = electrode_number - 1. The probe number is assumed - # to be a single digit (1, 2, or 3), matching the documented - # SpikeGadgets limit of three simultaneous Neuropixels probes. + # Each id is "<1-based electrode number>"; the leading digit + # identifies the probe (1, 2, or 3, matching the documented SpikeGadgets + # limit of three simultaneous Neuropixels probes) and the remainder is + # the 1-based electrode number on that probe (chind = electrode - 1). + # NP1.0 standard uses maxPadsPerProbe = 1000 (ids are 4 chars wide, e.g. + # "1384"); NP2.0 uses maxPadsPerProbe = 10000 (ids are 5 chars wide, e.g. + # "11672"). Slicing by [1:] handles both because the probe digit is + # always one char. The chind_to_catalogue_index function then remaps + # Trodes' channelsOn bit position to the catalogue's contact order + # (identity for NP1.0; row-major-to-shank-major remap for NP2.0 4-shank). electrode_to_hwchan = {} for ntrode in sconf: electrode_id = ntrode.attrib["id"] if int(electrode_id[0]) == curr_probe: - catalogue_index = int(electrode_id[1:]) - 1 + chind = int(electrode_id[1:]) - 1 + catalogue_index = chind_to_catalogue_index(chind) hw_chan = int(ntrode[0].attrib["hwChan"]) electrode_to_hwchan[catalogue_index] = hw_chan active_indices = np.array(sorted(electrode_to_hwchan.keys())) - full_probe = build_neuropixels_probe(PART_NUMBER) + full_probe = build_neuropixels_probe(part_number) probe = full_probe.get_slice(active_indices) - # Clear part-number-specific metadata since we don't know the actual part number. + # Clear part-number-specific metadata since the .rec XML does not carry + # a part number; the catalogue pick is a geometry-equivalence stand-in + # rather than a fact read from the file. probe.model_name = "" probe.description = "" @@ -814,10 +894,11 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> # Per-contact ADC group and sample order from the catalogue MUX table plus # the hwChan mapping (which is the readout-channel index for each contact). adc_sampling_table = probe.annotations.get("adc_sampling_table") - _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) + if adc_sampling_table is not None: + _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) - # NP1.0 gain is programmable. Read APGainMode and LFPGainMode from the - # SourceOptions block matching this probe (blocks appear in probe order). + # Neuropixels gain is programmable. Read APGainMode and LFPGainMode from + # the SourceOptions block matching this probe (blocks appear in probe order). if "ap_gain" not in probe.annotations and curr_probe - 1 < len(source_options_blocks): custom_options = { opt.attrib["name"]: opt.attrib["data"].strip() @@ -832,7 +913,7 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> probe.annotate(lf_gain=float(lf_gain_str)) # Shift multiple probes so they don't overlap when plotted - probe.move([250 * (curr_probe - 1), 0]) + probe.move([multi_probe_x_shift_um * (curr_probe - 1), 0]) probe_group.add_probe(probe) @@ -865,10 +946,10 @@ def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: Detection scans the ``HardwareConfiguration`` block of the ``.rec`` XML header for ``Device`` entries whose ``name`` attribute matches a known - Neuropixels source name (currently ``"NeuroPixels1"``). 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. + 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. Intended use: callers that route heterogeneous SpikeGadgets recordings (mixing tetrodes, Neuropixels, etc.) can gate the call to @@ -884,6 +965,8 @@ def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: ------- bool """ + neuropixels_source_names = {"NeuroPixels1", "NeuroPixels2"} + try: header_txt = parse_spikegadgets_header(file) root = ElementTree.fromstring(header_txt) @@ -895,7 +978,7 @@ def has_spikegadgets_neuropixels_probes(file: str | Path) -> bool: return False for device in hconf: - if device.attrib.get("name") == "NeuroPixels1": + if device.attrib.get("name") in neuropixels_source_names: return True return False diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py index b9ce69d5..fc28a4a5 100644 --- a/tests/test_io/test_spikegadgets.py +++ b/tests/test_io/test_spikegadgets.py @@ -13,6 +13,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" def test_parse_meta(): @@ -35,6 +36,60 @@ def test_neuropixels_1_reader(): assert probe_group.get_contact_count() == 768 +def test_neuropixels_2_4shank_reader(): + # This NP2.0 4-shank fixture activates 48 rows of electrodes across all 4 + # shanks (probeColumns 0-7), so it exercises the row-major-to-shank-major + # chind remapping defined in `_spikegadgets_chind_np2_4shank`. The recovered + # ml coordinates should match the SpikeChannel coord_ml values up to a + # single stereotactic offset (the workspace-baked probe origin). + import numpy as np + from xml.etree import ElementTree as ET + + probe_group = read_spikegadgets_neuropixels(data_path / test_file_np2_4shank, raise_error=False) + assert len(probe_group.probes) == 1 + probe = probe_group.probes[0] + probe_dict = probe.to_dict(array_as_list=True) + validate_probe_dict(probe_dict) + assert probe.model_name == "" + assert probe.get_contact_count() == 384 + assert probe.device_channel_indices.shape == (384,) + assert probe.get_shank_count() == 4 + # Each shank should contribute 96 contacts (48 rows × 2 cols per shank). + shank_ids = np.array(probe.shank_ids) + for shank in ("0", "1", "2", "3"): + assert (shank_ids == shank).sum() == 96, f"shank {shank} contact count" + assert all(cid.startswith("s") and "e" in cid for cid in probe.contact_ids) + + # Verify catalogue positions are consistent with .rec coord_ml/coord_dv up + # to a single stereotactic offset shared across all electrodes. + header_txt = parse_spikegadgets_header(data_path / test_file_np2_4shank) + root = ET.fromstring(header_txt) + sconf = root.find("SpikeConfiguration") + rec_positions = {} + for ntrode in sconf: + chind = int(ntrode.attrib["id"][1:]) - 1 + ch = ntrode.find("SpikeChannel") + rec_positions[chind] = (float(ch.attrib["coord_ml"]), float(ch.attrib["coord_dv"])) + # Sample 1: chind 1671 should land on s0e416 (shank 0, ml=0, dv=3120 in catalogue). + sample_chind = 1671 + ml_rec, dv_rec = rec_positions[sample_chind] + sample_idx_in_probe = list(probe.contact_ids).index("s0e416") + ml_cat, dv_cat = probe.contact_positions[sample_idx_in_probe] + offset_ml = ml_rec - ml_cat + offset_dv = dv_rec - dv_cat + # Sample 2: chind 1664 should land on s3e417. + ml_rec_2, dv_rec_2 = rec_positions[1664] + sample_idx_2 = list(probe.contact_ids).index("s3e417") + ml_cat_2, dv_cat_2 = probe.contact_positions[sample_idx_2] + assert abs((ml_rec_2 - ml_cat_2) - offset_ml) < 1e-6, "ml offset must be constant across shanks" + assert abs((dv_rec_2 - dv_cat_2) - offset_dv) < 1e-6, "dv offset must be constant across rows" + + +def test_has_spikegadgets_neuropixels_probes_np2(): + # NP2.0 4-shank .rec should also report True. + assert has_spikegadgets_neuropixels_probes(data_path / test_file_np2_4shank) is True + + def test_read_spikegadgets_deprecation_warning(): # Old read_spikegadgets name must still work but emit DeprecationWarning pointing at the new name. with pytest.warns(DeprecationWarning, match="read_spikegadgets_neuropixels"): @@ -54,6 +109,8 @@ def test_has_spikegadgets_neuropixels_probes_missing_file(): if __name__ == "__main__": test_parse_meta() test_neuropixels_1_reader() + test_neuropixels_2_4shank_reader() + test_has_spikegadgets_neuropixels_probes_np2() test_read_spikegadgets_deprecation_warning() test_has_spikegadgets_neuropixels_probes_positive() test_has_spikegadgets_neuropixels_probes_missing_file() From d1f3768a5fffbe5badc95889bf6279c16c271003 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 22 May 2026 09:01:04 -0600 Subject: [PATCH 3/9] remoe non-used method --- src/probeinterface/io.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 3bc07120..5395cab5 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -733,16 +733,6 @@ def write_csv(file, probe): raise NotImplementedError -def _spikegadgets_chind_identity(chind: int) -> int: - """``channelsOn`` bit position is the catalogue contact index directly. - - Used for NP1.0 standard: Trodes' ``channelsOn`` bitmask and the catalogue - ``NP1000`` share the same per-shank row-major ordering, so no remap is - needed. - """ - return chind - - def _spikegadgets_chind_np2_4shank(chind: int) -> int: """Remap NP2.0 4-shank ``channelsOn`` bit position to catalogue index. @@ -774,9 +764,10 @@ def _spikegadgets_chind_np2_4shank(chind: int) -> int: # 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 the function that maps a Trodes ``channelsOn`` bit position (chind, equal -# to ``electrode_id[1:] - 1`` in the .rec XML) to a probeinterface catalogue -# contact index. +# and (optionally) a function remapping Trodes' ``channelsOn`` bit position +# (chind, 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 @@ -786,8 +777,8 @@ def _spikegadgets_chind_np2_4shank(chind: int) -> int: # are cleared on the sliced probe in both cases because the XML does not # carry a part-number field. _SPIKEGADGETS_NEUROPIXELS_FORMATS = { - # (device, deviceSubType): (HardwareConfiguration device name, part_number, multi_probe_x_shift_um, chind_to_catalogue_index) - ("neuropixels1", "10"): ("NeuroPixels1", "NP1000", 250.0, _spikegadgets_chind_identity), + # (device, deviceSubType): (HardwareConfiguration device name, part_number, multi_probe_x_shift_um, chind_to_catalogue_index | None) + ("neuropixels1", "10"): ("NeuroPixels1", "NP1000", 250.0, None), ("neuropixels2", "4_SHANK"): ("NeuroPixels2", "NP2014", 1000.0, _spikegadgets_chind_np2_4shank), } From 4358812993fbf38759689a8022f7943723cd5b2f Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 22 May 2026 09:28:31 -0600 Subject: [PATCH 4/9] improvements --- src/probeinterface/io.py | 154 +++++++++++++++++++++++++-------------- 1 file changed, 98 insertions(+), 56 deletions(-) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 5395cab5..e8547a42 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -733,54 +733,55 @@ def write_csv(file, probe): raise NotImplementedError -def _spikegadgets_chind_np2_4shank(chind: int) -> int: +def _spikegadgets_channel_index_np2_4shank(channel_index: int) -> int: """Remap NP2.0 4-shank ``channelsOn`` bit position to catalogue index. Trodes writes ``channelsOn`` row-major across all four shanks (eight contacts per row, two columns per shank), with the column-within-row - direction reversed relative to ``probeColumn`` (high ``chind`` -> low + direction reversed relative to ``probeColumn`` (high ``channel_index`` -> low ``probeColumn``; see Trodes `configuration.cpp:5374-5421` and the `probeColumn` annotations in the .rec ``SpikeChannel`` elements). The catalogue (``NP2014``) is shank-major instead (s0e0..s0e1279, - s1e0..s1e1279, ...), so chind needs remapping. Verified empirically - against the SpikeGadgets-provided NP2.0 4-shank fixture: chind 1671 with - ``probeColumn="0"`` maps to ``s0e416``, chind 1664 with ``probeColumn="7"`` - maps to ``s3e417``, and the recovered ml/dv values match the catalogue - positions up to a single stereotactic offset. + s1e0..s1e1279, ...), so channel_index needs remapping. Verified empirically + against the SpikeGadgets-provided NP2.0 4-shank fixture: channel_index 1671 with + ``probeColumn="0"`` maps to ``s0e416``, channel_index 1664 with ``probeColumn="7"`` + maps to ``s3e417``, and the .rec ``coord_ml``/``coord_dv`` values for those + SpikeChannel entries match the catalogue positions up to a single stereotactic + offset (these XML coords are not consumed by the reader, only used by the + test in `tests/test_io/test_spikegadgets.py` as an independent cross-check). """ CONTACTS_PER_ROW = 8 # 2 columns per shank * 4 shanks COLS_PER_SHANK = 2 CONTACTS_PER_SHANK = 1280 - row = chind // CONTACTS_PER_ROW - col_global = (CONTACTS_PER_ROW - 1) - (chind % CONTACTS_PER_ROW) + row = channel_index // CONTACTS_PER_ROW + col_global = (CONTACTS_PER_ROW - 1) - (channel_index % CONTACTS_PER_ROW) shank = col_global // COLS_PER_SHANK col_on_shank = col_global % COLS_PER_SHANK return shank * CONTACTS_PER_SHANK + row * COLS_PER_SHANK + col_on_shank -# Dispatch for `read_spikegadgets_neuropixels`, 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 -# (chind, 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 = { - # (device, deviceSubType): (HardwareConfiguration device name, part_number, multi_probe_x_shift_um, chind_to_catalogue_index | None) - ("neuropixels1", "10"): ("NeuroPixels1", "NP1000", 250.0, None), - ("neuropixels2", "4_SHANK"): ("NeuroPixels2", "NP2014", 1000.0, _spikegadgets_chind_np2_4shank), -} +def _spikegadgets_channel_index_np2_1shank(channel_index: int) -> int: + """Remap NP2.0 single-shank ``channelsOn`` bit position to catalogue index. + + Same row-major-within-probe layout as NP2.0 4-shank (Trodes + `configuration.cpp:5279-5290`) but with only one shank and two + columns per row, so two contacts per row. The within-row direction is + reversed relative to the catalogue (extrapolated from NP2.0 4-shank + where this was empirically verified): channel_index 0 -> right column, channel_index 1 + -> left column, channel_index 2 -> next row right, etc. The catalogue + (``NP2000``) lays out contacts with left column first (idx 0 = left, + idx 1 = right per row), so the remap pairs are swapped: + catalogue_idx = row * 2 + (1 - channel_index % 2). + + Unverified against a real fixture; will be revisited when a NP2.0 + single-shank .rec from a Bennu rig becomes available. + """ + COLS_PER_SHANK = 2 + + row = channel_index // COLS_PER_SHANK + col_on_shank = (COLS_PER_SHANK - 1) - (channel_index % COLS_PER_SHANK) + return row * COLS_PER_SHANK + col_on_shank def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> ProbeGroup: @@ -790,11 +791,14 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> and information for all probes will be returned in a ProbeGroup object. Supported Neuropixels variants: NP1.0 standard (``device="neuropixels1"``, - older recordings without ``deviceSubType`` are treated as standard) and - NP2.0 4-shank (``device="neuropixels2" deviceSubType="4_SHANK"``). - Other Neuropixels variants Trodes can describe (NP1.0 HD/NHP, NP2.0 single-shank, - NRIC) raise ``ValueError`` for now; non-Neuropixels probes (tetrodes etc.) are - not handled at all. Use :func:`has_spikegadgets_neuropixels_probes` to check + older recordings without ``deviceSubType`` are treated as standard), + NP2.0 single-shank (``device="neuropixels2" deviceSubType="1_SHANK"``), + and NP2.0 4-shank (``device="neuropixels2" deviceSubType="4_SHANK"``). + The single-shank channel_index remap is extrapolated from the 4-shank pattern and + has not been verified against a real fixture yet. Other Neuropixels variants + Trodes can describe (NP1.0 HD, NP1.0 NHP short/medium/long, NRIC) raise + ``ValueError`` for now; non-Neuropixels probes (tetrodes etc.) are not + handled at all. Use :func:`has_spikegadgets_neuropixels_probes` to check whether a ``.rec`` file contains Neuropixels probe geometry before calling this reader. @@ -808,6 +812,44 @@ 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") @@ -820,30 +862,25 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> 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: + 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"{sorted(spikegadgets_neuropixels_formats)}" ) - ( - hconf_device_name, - part_number, - multi_probe_x_shift_um, - chind_to_catalogue_index, - ) = _SPIKEGADGETS_NEUROPIXELS_FORMATS[dispatch_key] - - probe_configs = [d for d in hconf if d.attrib.get("name") == hconf_device_name] + 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) if n_probes == 0: if raise_error: - raise Exception(f"No {hconf_device_name} probes found") + raise Exception(f"No {fmt['hardware_device_name']} probes found") return None # SourceOptions blocks carry the per-probe AP/LF gain settings. They appear # in the same order as the SpikeNTrode probe digits (1, 2, 3). - source_options_blocks = [s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == hconf_device_name] + source_options_blocks = [s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == fmt["hardware_device_name"]] probe_group = ProbeGroup() @@ -852,25 +889,30 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> # Each id is "<1-based electrode number>"; the leading digit # identifies the probe (1, 2, or 3, matching the documented SpikeGadgets # limit of three simultaneous Neuropixels probes) and the remainder is - # the 1-based electrode number on that probe (chind = electrode - 1). + # the 1-based electrode number on that probe (channel_index = electrode - 1). # NP1.0 standard uses maxPadsPerProbe = 1000 (ids are 4 chars wide, e.g. # "1384"); NP2.0 uses maxPadsPerProbe = 10000 (ids are 5 chars wide, e.g. # "11672"). Slicing by [1:] handles both because the probe digit is - # always one char. The chind_to_catalogue_index function then remaps - # Trodes' channelsOn bit position to the catalogue's contact order - # (identity for NP1.0; row-major-to-shank-major remap for NP2.0 4-shank). + # always one char. The format's channel_index_to_catalogue_index function + # then remaps Trodes' channelsOn bit position to the catalogue's contact + # order; it is None when no remap is needed (NP1.0, where the catalogue + # happens to be in Trodes' bit order already). electrode_to_hwchan = {} for ntrode in sconf: electrode_id = ntrode.attrib["id"] if int(electrode_id[0]) == curr_probe: - chind = int(electrode_id[1:]) - 1 - catalogue_index = chind_to_catalogue_index(chind) + channel_index = int(electrode_id[1:]) - 1 + catalogue_index = ( + channel_index + if fmt["channel_index_to_catalogue_index"] is None + else fmt["channel_index_to_catalogue_index"](channel_index) + ) hw_chan = int(ntrode[0].attrib["hwChan"]) electrode_to_hwchan[catalogue_index] = hw_chan active_indices = np.array(sorted(electrode_to_hwchan.keys())) - full_probe = build_neuropixels_probe(part_number) + full_probe = build_neuropixels_probe(fmt["part_number"]) probe = full_probe.get_slice(active_indices) # Clear part-number-specific metadata since the .rec XML does not carry @@ -904,7 +946,7 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> probe.annotate(lf_gain=float(lf_gain_str)) # Shift multiple probes so they don't overlap when plotted - probe.move([multi_probe_x_shift_um * (curr_probe - 1), 0]) + probe.move([fmt["multi_probe_plot_offset_um"] * (curr_probe - 1), 0]) probe_group.add_probe(probe) From e73e1028e2c724c4d78a5fd03c146da59c4b2a9f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:36:23 +0000 Subject: [PATCH 5/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/probeinterface/io.py | 4 +++- ...eGadgets_test_data_NP2_4shank_20260122_header_only.rec | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index e8547a42..9b2e6f72 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -880,7 +880,9 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> # SourceOptions blocks carry the per-probe AP/LF gain settings. They appear # in the same order as the SpikeNTrode probe digits (1, 2, 3). - source_options_blocks = [s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == fmt["hardware_device_name"]] + source_options_blocks = [ + s for s in hconf.findall("SourceOptions") if s.attrib.get("name") == fmt["hardware_device_name"] + ] probe_group = ProbeGroup() diff --git a/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec b/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec index b955c7e2..07ebe2e3 100644 --- a/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec +++ b/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec @@ -1233,14 +1233,14 @@ U   P 0p @Pp @@P`PPPp `@P@P` ``` @@d p`00`@P P -``` P`@0   p`0p@p0P` PP@000@0p@0`` `p``  P@P @p0@00 P0p PP0`0 @@p`@0 `p` -`P`pp @ 0P`UGW+ pPp0P0p 0  +``` P`@0   p`0p@p0P` PP@000@0p@0`` `p``  P@P @p0@00 P0p PP0`0 @@p`@0 `p` +`P`pp @ 0P`UGW+ pPp0P0p 0  0  p  pp@`@`p `P@@pp @0` @p `P@P 00_`@`@`@   P`p0@  -`0@@ 0Pp 0p@0@0` P@``P0Pp`0@ PPP`pPPP p` pP +`0@@ 0Pp 0p@0@0` P@``P0Pp`0@ PPP`pPPP p` pP @ 0 p`p   ` @P` p@p`pp` U0QW+  P `@p@P Ppp PPPPp  ```P` @PPP@00 ` p p0` Pp@ Y pPp P00` P P P@ `  P P` `@ ` @ @0  ``0P PpP @`0`p@P0p `pP0 p0pP@`PpP @`PpP0P PP  @`  @0  `PPp PPP0p@`U ZW+ pp` PP@` @P@0P@P 0p` `@ @`p - \ No newline at end of file + From a9b7d9c34a4e704c45213e7b1271160cd2543067 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 22 May 2026 09:41:20 -0600 Subject: [PATCH 6/9] also add stereotactic --- src/probeinterface/io.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index e8547a42..97a18683 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -898,6 +898,7 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> # order; it is None when no remap is needed (NP1.0, where the catalogue # happens to be in Trodes' bit order already). electrode_to_hwchan = {} + electrode_to_stereotactic = {} for ntrode in sconf: electrode_id = ntrode.attrib["id"] if int(electrode_id[0]) == curr_probe: @@ -907,8 +908,13 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> if fmt["channel_index_to_catalogue_index"] is None else fmt["channel_index_to_catalogue_index"](channel_index) ) - hw_chan = int(ntrode[0].attrib["hwChan"]) - electrode_to_hwchan[catalogue_index] = hw_chan + spike_channel = ntrode[0] + electrode_to_hwchan[catalogue_index] = int(spike_channel.attrib["hwChan"]) + electrode_to_stereotactic[catalogue_index] = ( + float(spike_channel.attrib["coord_ml"]), + float(spike_channel.attrib["coord_dv"]), + float(spike_channel.attrib["coord_ap"]), + ) active_indices = np.array(sorted(electrode_to_hwchan.keys())) @@ -924,6 +930,20 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices]) probe.set_device_channel_indices(device_channels) + # Stereotactic coordinates from the .rec SpikeChannel attributes + # (workspace probe origin + on-probe offset, in micrometres; see Trodes + # `configuration.cpp:5443-5445`). These are recording-specific surgical + # metadata, distinct from `contact_positions` which carries the pure + # on-probe catalogue geometry. We attach them as per-contact annotations + # so downstream code that wants stereotactic locations (e.g. histology + # registration) can read them without re-parsing the XML. + stereotactic = np.array([electrode_to_stereotactic[idx] for idx in active_indices]) + probe.annotate_contacts( + stereotactic_ml=stereotactic[:, 0], + stereotactic_dv=stereotactic[:, 1], + stereotactic_ap=stereotactic[:, 2], + ) + # Per-contact ADC group and sample order from the catalogue MUX table plus # the hwChan mapping (which is the readout-channel index for each contact). adc_sampling_table = probe.annotations.get("adc_sampling_table") From e0bf8fdcfce8ce2f19010d67b7184dd019a03620 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 22 May 2026 09:44:10 -0600 Subject: [PATCH 7/9] add stereotactic coordinates --- tests/test_io/test_spikegadgets.py | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py index fc28a4a5..eb58f7ca 100644 --- a/tests/test_io/test_spikegadgets.py +++ b/tests/test_io/test_spikegadgets.py @@ -85,6 +85,39 @@ def test_neuropixels_2_4shank_reader(): assert abs((dv_rec_2 - dv_cat_2) - offset_dv) < 1e-6, "dv offset must be constant across rows" +def test_stereotactic_annotations_np1(): + # SpikeChannel coord_ml/dv/ap from the .rec are stored as per-contact + # annotations on the output probe. Sentinel: chind 383 (id "1384" on probe + # 1) maps to catalogue idx 383 (e383) under identity remap; the matching + # SpikeChannel has coord_ml="-8" coord_dv="3920" coord_ap="0". + probe_group = read_spikegadgets_neuropixels(data_path / test_file) + probe = probe_group.probes[0] + n_contacts = probe.get_contact_count() + for key in ("stereotactic_ml", "stereotactic_dv", "stereotactic_ap"): + assert key in probe.contact_annotations + assert probe.contact_annotations[key].shape == (n_contacts,) + i = list(probe.contact_ids).index("e383") + assert probe.contact_annotations["stereotactic_ml"][i] == -8.0 + assert probe.contact_annotations["stereotactic_dv"][i] == 3920.0 + assert probe.contact_annotations["stereotactic_ap"][i] == 0.0 + + +def test_stereotactic_annotations_np2_4shank(): + # Same check for NP2.0 4-shank: chind 1671 maps to catalogue idx 416 + # (s0e416) via the row-major-to-shank-major remap; the matching SpikeChannel + # has coord_ml="-383" coord_dv="3295" coord_ap="0". + probe_group = read_spikegadgets_neuropixels(data_path / test_file_np2_4shank) + probe = probe_group.probes[0] + n_contacts = probe.get_contact_count() + for key in ("stereotactic_ml", "stereotactic_dv", "stereotactic_ap"): + assert key in probe.contact_annotations + assert probe.contact_annotations[key].shape == (n_contacts,) + i = list(probe.contact_ids).index("s0e416") + assert probe.contact_annotations["stereotactic_ml"][i] == -383.0 + assert probe.contact_annotations["stereotactic_dv"][i] == 3295.0 + assert probe.contact_annotations["stereotactic_ap"][i] == 0.0 + + def test_has_spikegadgets_neuropixels_probes_np2(): # NP2.0 4-shank .rec should also report True. assert has_spikegadgets_neuropixels_probes(data_path / test_file_np2_4shank) is True @@ -110,6 +143,8 @@ def test_has_spikegadgets_neuropixels_probes_missing_file(): test_parse_meta() test_neuropixels_1_reader() test_neuropixels_2_4shank_reader() + test_stereotactic_annotations_np1() + test_stereotactic_annotations_np2_4shank() test_has_spikegadgets_neuropixels_probes_np2() test_read_spikegadgets_deprecation_warning() test_has_spikegadgets_neuropixels_probes_positive() From c1abdab6e524fcb6e9dc21e3d921f22c095971c4 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 22 May 2026 09:59:48 -0600 Subject: [PATCH 8/9] also remove part number --- src/probeinterface/io.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 94fd98fb..7e64b344 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -928,6 +928,7 @@ def read_spikegadgets_neuropixels(file: str | Path, raise_error: bool = True) -> # rather than a fact read from the file. probe.model_name = "" probe.description = "" + probe.annotations.pop("part_number", None) device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices]) probe.set_device_channel_indices(device_channels) From 6d69d9d405e88cc2cffa0a1737a39402e740398f Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 22 May 2026 10:12:23 -0600 Subject: [PATCH 9/9] Cite SpikeGadgets author confirmation in NP2.0 4-shank remap docstring Mattias Karlsson, the Trodes author, confirmed the channelsOn layout empirically derived in #441 on the PR thread. Quote the relevant description directly in the docstring so the formula has a primary-source citation next to it, not just the fixture-based verification. Co-Authored-By: Roberto <37729096+RobertoDF@users.noreply.github.com> --- src/probeinterface/io.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 7e64b344..68f82516 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -749,6 +749,12 @@ def _spikegadgets_channel_index_np2_4shank(channel_index: int) -> int: SpikeChannel entries match the catalogue positions up to a single stereotactic offset (these XML coords are not consumed by the reader, only used by the test in `tests/test_io/test_spikegadgets.py` as an independent cross-check). + Independently confirmed in May 2026 by Mattias Karlsson (SpikeGadgets / + Trodes author) on PR #441: "the 2.0 four-shank probe has two columns per + shank... the first electrode on the probe (starts with 1) is in the lower + right, and number 10008 is on the lower left. Then, 10009 is the second + row on the right, and so on", which is exactly the (row, col_global=7-x) + layout this function encodes. """ CONTACTS_PER_ROW = 8 # 2 columns per shank * 4 shanks COLS_PER_SHANK = 2