From 1b8e0d01dfeea933d7823f9d7cf44dd5ba7b09d9 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 21 May 2026 20:53:56 -0600 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] [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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 From 2eed701c6d80fcaeee6efabee13b9e521e0a94b3 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 9 Jun 2026 07:17:21 -0600 Subject: [PATCH 10/10] move to neurpixels tools --- src/probeinterface/__init__.py | 7 +- src/probeinterface/io.py | 336 ----------------------- src/probeinterface/neuropixels_tools.py | 339 ++++++++++++++++++++++++ tests/test_io/test_spikegadgets.py | 2 +- 4 files changed, 344 insertions(+), 340 deletions(-) diff --git a/src/probeinterface/__init__.py b/src/probeinterface/__init__.py index 45e102bf..25b48aa2 100644 --- a/src/probeinterface/__init__.py +++ b/src/probeinterface/__init__.py @@ -14,9 +14,6 @@ write_csv, read_BIDS_probe, write_BIDS_probe, - read_spikegadgets, - read_spikegadgets_neuropixels, - has_spikegadgets_neuropixels_probes, read_mearec, read_nwb, read_maxwell, @@ -33,6 +30,10 @@ read_openephys_neuropixels, has_neuropixels_probes, get_saved_channel_indices_from_openephys_settings, + read_spikegadgets, + read_spikegadgets_neuropixels, + has_spikegadgets_neuropixels_probes, + parse_spikegadgets_header, ) from .utils import combine_probes from .generator import ( diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 68f82516..bd3ba609 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -11,17 +11,13 @@ from pathlib import Path import re -import warnings import json from collections import OrderedDict from packaging.version import parse import numpy as np -from xml.etree import ElementTree - from . import __version__ from .probe import Probe from .probegroup import ProbeGroup -from .neuropixels_tools import build_neuropixels_probe, _annotate_probe_with_adc_sampling_info from .utils import import_safely @@ -733,338 +729,6 @@ def write_csv(file, probe): raise NotImplementedError -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 ``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 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). - 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 - CONTACTS_PER_SHANK = 1280 - - 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 - - -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: - """ - Find active channels of the given Neuropixels probe from a SpikeGadgets .rec file. - SpikeGadgets headstages support up to three Neuropixels probes simultaneously, - 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), - 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. - - Parameters - ---------- - file : Path or str - The .rec file path - - Returns - ------- - 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: - raise ValueError( - f"Unsupported SpikeGadgets Neuropixels variant device={sconf_device!r} " - f"deviceSubType={sconf_subtype!r}; supported: " - f"{sorted(spikegadgets_neuropixels_formats)}" - ) - 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 {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") == fmt["hardware_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>"; 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 (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 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 = {} - electrode_to_stereotactic = {} - for ntrode in sconf: - electrode_id = ntrode.attrib["id"] - if int(electrode_id[0]) == curr_probe: - 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) - ) - 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())) - - 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 - # 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 = "" - 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) - - # 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") - if adc_sampling_table is not None: - _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) - - # 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() - for opt in source_options_blocks[curr_probe - 1].findall("CustomOption") - } - ap_gain_str = custom_options.get("APGainMode") - if ap_gain_str: - probe.annotate(ap_gain=float(ap_gain_str)) - if probe.annotations.get("lf_sample_frequency_hz", 0) > 0: - lf_gain_str = custom_options.get("LFPGainMode") - if lf_gain_str: - probe.annotate(lf_gain=float(lf_gain_str)) - - # Shift multiple probes so they don't overlap when plotted - probe.move([fmt["multi_probe_plot_offset_um"] * (curr_probe - 1), 0]) - - probe_group.add_probe(probe) - - 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 (``"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 - :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 - """ - neuropixels_source_names = {"NeuroPixels1", "NeuroPixels2"} - - 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") in neuropixels_source_names: - return True - return False - - -def parse_spikegadgets_header(file: str | Path) -> str: - """ - Parse file (SpikeGadgets .rec format) into a string until "", - which is the last tag of the header, after which the binary data begins. - """ - header_size = None - with open(file, mode="rb") as f: - while True: - line = f.readline() - if b"" in line: - header_size = f.tell() - break - - if header_size is None: - ValueError("SpikeGadgets: the xml header does not contain ''") - - f.seek(0) - return f.read(header_size).decode("utf8") - - def read_mearec(file: str | Path) -> Probe: """ Read probe position, and contact shape from a MEArec file. diff --git a/src/probeinterface/neuropixels_tools.py b/src/probeinterface/neuropixels_tools.py index 794677c7..ef09d605 100644 --- a/src/probeinterface/neuropixels_tools.py +++ b/src/probeinterface/neuropixels_tools.py @@ -12,8 +12,10 @@ from packaging.version import parse import json import numpy as np +from xml.etree import ElementTree from .probe import Probe +from .probegroup import ProbeGroup from .utils import import_safely global _np_probe_features @@ -1759,3 +1761,340 @@ def get_saved_channel_indices_from_openephys_settings(settings_file: str | Path, if recording_state not in ("ALL", "NONE"): chans_saved = np.array([chan for chan, r in enumerate(recording_state) if int(r) == 1]) return chans_saved + + +###################### +# SpikeGadgets zone # +###################### + + +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 ``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 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). + 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 + CONTACTS_PER_SHANK = 1280 + + 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 + + +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: + """ + Find active channels of the given Neuropixels probe from a SpikeGadgets .rec file. + SpikeGadgets headstages support up to three Neuropixels probes simultaneously, + 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), + 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. + + Parameters + ---------- + file : Path or str + The .rec file path + + Returns + ------- + 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: + raise ValueError( + f"Unsupported SpikeGadgets Neuropixels variant device={sconf_device!r} " + f"deviceSubType={sconf_subtype!r}; supported: " + f"{sorted(spikegadgets_neuropixels_formats)}" + ) + 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 {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") == fmt["hardware_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>"; 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 (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 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 = {} + electrode_to_stereotactic = {} + for ntrode in sconf: + electrode_id = ntrode.attrib["id"] + if int(electrode_id[0]) == curr_probe: + 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) + ) + 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())) + + 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 + # 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 = "" + 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) + + # 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") + if adc_sampling_table is not None: + _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) + + # 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() + for opt in source_options_blocks[curr_probe - 1].findall("CustomOption") + } + ap_gain_str = custom_options.get("APGainMode") + if ap_gain_str: + probe.annotate(ap_gain=float(ap_gain_str)) + if probe.annotations.get("lf_sample_frequency_hz", 0) > 0: + lf_gain_str = custom_options.get("LFPGainMode") + if lf_gain_str: + probe.annotate(lf_gain=float(lf_gain_str)) + + # Shift multiple probes so they don't overlap when plotted + probe.move([fmt["multi_probe_plot_offset_um"] * (curr_probe - 1), 0]) + + probe_group.add_probe(probe) + + 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 (``"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 + :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 + """ + neuropixels_source_names = {"NeuroPixels1", "NeuroPixels2"} + + 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") in neuropixels_source_names: + return True + return False + + +def parse_spikegadgets_header(file: str | Path) -> str: + """ + Parse file (SpikeGadgets .rec format) into a string until "", + which is the last tag of the header, after which the binary data begins. + """ + header_size = None + with open(file, mode="rb") as f: + while True: + line = f.readline() + if b"" in line: + header_size = f.tell() + break + + if header_size is None: + ValueError("SpikeGadgets: the xml header does not contain ''") + + f.seek(0) + return f.read(header_size).decode("utf8") diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py index eb58f7ca..890d3f42 100644 --- a/tests/test_io/test_spikegadgets.py +++ b/tests/test_io/test_spikegadgets.py @@ -8,7 +8,7 @@ read_spikegadgets_neuropixels, has_spikegadgets_neuropixels_probes, ) -from probeinterface.io import parse_spikegadgets_header +from probeinterface.neuropixels_tools import parse_spikegadgets_header from probeinterface.testing import validate_probe_dict data_path = Path(__file__).absolute().parent.parent / "data" / "spikegadgets"