From 38522cc34a0ae30d2bf73c8333a1dcd0e0f80689 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 24 Mar 2026 19:03:02 -0600 Subject: [PATCH 1/4] Add probe catalogue path to spikegadgets --- src/probeinterface/io.py | 152 ++++++++--------------------- tests/test_io/test_spikegadgets.py | 2 +- 2 files changed, 43 insertions(+), 111 deletions(-) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index ad1a60ae..66012c23 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -21,6 +21,7 @@ from . import __version__ from .probe import Probe from .probegroup import ProbeGroup +from .neuropixels_tools import build_neuropixels_probe from .utils import import_safely @@ -749,15 +750,6 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: probe_group : ProbeGroup object """ - # ------------------------- # - # Npix 1.0 constants # - # ------------------------- # - TOTAL_NPIX_ELECTRODES = 960 - MAX_ACTIVE_CHANNELS = 384 - CONTACT_WIDTH = 16 # um - CONTACT_HEIGHT = 20 # um - # ------------------------- # - # Read the header and get Configuration elements header_txt = parse_spikegadgets_header(file) root = ElementTree.fromstring(header_txt) @@ -773,122 +765,62 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: raise Exception("No Neuropixels 1.0 probes found") return None - # Container to store Probe objects probe_group = ProbeGroup() for curr_probe in range(1, n_probes + 1): probe_config = probe_configs[curr_probe - 1] - # Get number of active channels from probe Device element + # Get active electrode indices from the channelsOn bitmask (960 elements, 0-indexed) active_channel_str = [option for option in probe_config if option.attrib["name"] == "channelsOn"][0].attrib[ "data" ] - active_channels = [int(ch) for ch in active_channel_str.split(" ") if ch] - n_active_channels = sum(active_channels) - assert len(active_channels) == TOTAL_NPIX_ELECTRODES - assert n_active_channels <= MAX_ACTIVE_CHANNELS - - """ - Within the SpikeConfiguration header element (sconf), there is a SpikeNTrode element - for each electrophysiology channel that contains information relevant to scaling and - otherwise displaying the information from that channel, as well as the id of the electrode - from which it is recording ('id'). - - Nested within each SpikeNTrode element is a SpikeChannel element with information about - the electrode dynamically connected to that channel. This contains information relevant - for spike sorting, i.e., its spatial location along the probe shank and the hardware channel - to which it is connected. - - Excerpt of a sample SpikeConfiguration element: - - - - - - ... - - """ - # Find all channels/electrodes that belong to the current probe - contact_ids = [] - device_channels = [] - positions = np.zeros((n_active_channels, 2)) - - nt_i = 0 # Both probes are in sconf, so need an independent counter of probe electrodes while iterating through + channels_on = np.array([int(ch) for ch in active_channel_str.split(" ") if ch]) + active_indices = np.nonzero(channels_on)[0] + + # Build full catalogue probe and slice to active electrodes. + # + # The SpikeGadgets XML format does not include the probe part number, so we + # hardcode "NP1000" (standard Neuropixels 1.0). This is safe because the + # Bennu manual (Rev3, 2025) explicitly states support for "Neuropixels 1.0 + # probes (every version except NHP) OR Neuropixels 2.0". The supported + # NP 1.0 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 catalogue: same + # contact positions, pitch, stagger, shank width, tip length, shank length, + # contour, electrode count, and ADC/MUX tables. The only fields that differ + # are metadata (description, datasheet, is_commercial) and shank_thickness_um + # (Z-axis), none of which probeinterface uses. + # + # NP 2.0 support: the Bennu uses a different cone and firmware for 2.0 + # probes, and the workspace creation step distinguishes "Neuropixels 1.0" + # from "Neuropixels 2.0". When .rec files from NP 2.0 recordings become + # available, this reader will need to detect the probe type (likely from + # the device name in the XML) and call build_neuropixels_probe with the + # appropriate part number. + full_probe = build_neuropixels_probe("NP1000") + probe = full_probe.get_slice(active_indices) + + # Clear part-number-specific metadata since we don't know the actual part number. + probe.model_name = "" + probe.description = "" + + # Parse SpikeNTrode elements to build the device channel mapping. + # Each SpikeNTrode has an id like "1384" where the first digit is the probe number + # and the remaining digits are the 1-based electrode number. The catalogue uses + # 0-based electrode indices, so catalogue_index = electrode_number - 1. + electrode_to_hwchan = {} for ntrode in sconf: electrode_id = ntrode.attrib["id"] - if int(electrode_id[0]) == curr_probe: # first digit of electrode id is probe number - contact_ids.append(electrode_id) - positions[nt_i, :] = (ntrode[0].attrib["coord_ml"], ntrode[0].attrib["coord_dv"]) - device_channels.append(ntrode[0].attrib["hwChan"]) - nt_i += 1 - assert len(contact_ids) == n_active_channels - - # Construct Probe object - probe = Probe(ndim=2, si_units="um", model_name="Neuropixels 1.0", manufacturer="IMEC") - probe.set_contacts( - contact_ids=contact_ids, - positions=positions, - shapes="square", - shank_ids=None, - shape_params={"width": CONTACT_WIDTH, "height": CONTACT_HEIGHT}, - ) + if int(electrode_id[0]) == curr_probe: + catalogue_index = int(electrode_id[1:]) - 1 + hw_chan = int(ntrode[0].attrib["hwChan"]) + electrode_to_hwchan[catalogue_index] = hw_chan - # Wire it (i.e., point contact/electrode ids to corresponding hardware/channel ids) + device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices]) probe.set_device_channel_indices(device_channels) - # Create a nice polygon background when plotting the probes - x_min = positions[:, 0].min() - x_max = positions[:, 0].max() - x_mid = 0.5 * (x_max + x_min) - y_min = positions[:, 1].min() - y_max = positions[:, 1].max() - polygon_default = [ - (x_min - 20, y_min - CONTACT_HEIGHT / 2), - (x_mid, y_min - 100), - (x_max + 20, y_min - CONTACT_HEIGHT / 2), - (x_max + 20, y_max + 20), - (x_min - 20, y_max + 20), - ] - probe.set_planar_contour(polygon_default) - - # If there are multiple probes, they must be shifted such that they don't occupy the same coordinates. + # Shift multiple probes so they don't overlap when plotted probe.move([250 * (curr_probe - 1), 0]) - # Add the probe to the probe container probe_group.add_probe(probe) return probe_group diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py index 99770a35..a42e8eb6 100644 --- a/tests/test_io/test_spikegadgets.py +++ b/tests/test_io/test_spikegadgets.py @@ -23,7 +23,7 @@ def test_neuropixels_1_reader(): for probe in probe_group.probes: probe_dict = probe.to_dict(array_as_list=True) validate_probe_dict(probe_dict) - assert "1.0" in probe.model_name + assert probe.model_name == "" assert probe.get_shank_count() == 1 assert probe.get_contact_count() == 384 assert probe_group.get_contact_count() == 768 From f5bdd08cd8528be69aa117384a070eb1bc7e7b7e Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 20 Apr 2026 12:48:42 -0600 Subject: [PATCH 2/4] Add spikegadgets 2.0 support --- .gitignore | 1 + src/probeinterface/io.py | 87 +- ...t_data_NP2_4shank_20260122_header_only.rec | 1246 +++++++++++++++++ tests/test_io/test_spikegadgets.py | 18 + 4 files changed, 1308 insertions(+), 44 deletions(-) create mode 100644 tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec diff --git a/.gitignore b/.gitignore index 0ee5de65..52a45ce4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ uv.lock # libraries **/neuropixels_library_generated **/cambridgeneurotech_library +.codex diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 66012c23..a267205e 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -733,10 +733,29 @@ def write_csv(file, probe): raise NotImplementedError +_SPIKEGADGETS_NEUROPIXELS_FORMATS = { + # SpikeConfiguration.device -> (HardwareConfiguration device name, hardcoded part number, multi-probe x-shift um) + # + # The SpikeGadgets .rec XML does not include a probe part number. For each + # family (NP1 and NP2 4-shank) the listed catalogue variants share identical + # 2D geometry in the probeinterface catalogue (contact positions, pitch, + # stagger, shank spacing, shank width), differing only in metadata that + # probeinterface does not consume (ADC resolution, databus phase, gain, + # on-shank reference, shank thickness). So hardcoding one representative + # part number produces correct geometry. `model_name` and `description` are + # cleared on the sliced probe to avoid claiming a specific variant. + # + # NP1 family: NP1000, NP1001, PRB_1_2_0480_2, PRB_1_4_0480_1, PRB_1_4_0480_1_C. + # NP2 4-shank family: NP2010, NP2013, NP2014, NP2020, NP2021. + "neuropixels1": ("NeuroPixels1", "NP1000", 250.0), + "neuropixels2": ("NeuroPixels2", "NP2014", 1000.0), +} + + def read_spikegadgets(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 (1.0 or 2.0), and information for all probes will be returned in a ProbeGroup object. @@ -750,63 +769,34 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: probe_group : ProbeGroup object """ - # Read the header and get Configuration elements header_txt = parse_spikegadgets_header(file) root = ElementTree.fromstring(header_txt) hconf = root.find("HardwareConfiguration") sconf = root.find("SpikeConfiguration") - # Get number of probes present (each has its own Device element) - probe_configs = [device for device in hconf if device.attrib["name"] == "NeuroPixels1"] + # SpikeConfiguration.device selects the Neuropixels family. Default to NP1 + # when absent to preserve behavior for older files that predate the attribute. + sconf_device = (sconf.attrib.get("device", "") if sconf is not None else "").lower() + if sconf_device not in _SPIKEGADGETS_NEUROPIXELS_FORMATS: + sconf_device = "neuropixels1" + hc_device_name, part_number, multi_probe_x_shift_um = _SPIKEGADGETS_NEUROPIXELS_FORMATS[sconf_device] + + probe_configs = [d for d in hconf if d.attrib.get("name") == hc_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 {hc_device_name} devices found in SpikeGadgets .rec header") return None probe_group = ProbeGroup() for curr_probe in range(1, n_probes + 1): - probe_config = probe_configs[curr_probe - 1] - - # Get active electrode indices from the channelsOn bitmask (960 elements, 0-indexed) - active_channel_str = [option for option in probe_config if option.attrib["name"] == "channelsOn"][0].attrib[ - "data" - ] - channels_on = np.array([int(ch) for ch in active_channel_str.split(" ") if ch]) - active_indices = np.nonzero(channels_on)[0] - - # Build full catalogue probe and slice to active electrodes. - # - # The SpikeGadgets XML format does not include the probe part number, so we - # hardcode "NP1000" (standard Neuropixels 1.0). This is safe because the - # Bennu manual (Rev3, 2025) explicitly states support for "Neuropixels 1.0 - # probes (every version except NHP) OR Neuropixels 2.0". The supported - # NP 1.0 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 catalogue: same - # contact positions, pitch, stagger, shank width, tip length, shank length, - # contour, electrode count, and ADC/MUX tables. The only fields that differ - # are metadata (description, datasheet, is_commercial) and shank_thickness_um - # (Z-axis), none of which probeinterface uses. - # - # NP 2.0 support: the Bennu uses a different cone and firmware for 2.0 - # probes, and the workspace creation step distinguishes "Neuropixels 1.0" - # from "Neuropixels 2.0". When .rec files from NP 2.0 recordings become - # available, this reader will need to detect the probe type (likely from - # the device name in the XML) and call build_neuropixels_probe with the - # appropriate part number. - full_probe = build_neuropixels_probe("NP1000") - probe = full_probe.get_slice(active_indices) - - # Clear part-number-specific metadata since we don't know the actual part number. - probe.model_name = "" - probe.description = "" - - # Parse SpikeNTrode elements to build the device channel mapping. - # Each SpikeNTrode has an id like "1384" where the first digit is the probe number - # and the remaining digits are the 1-based electrode number. The catalogue uses + # SpikeNTrode elements are the authoritative list of recorded electrodes. + # Each id is "<1-based electrode number>"; the catalogue uses # 0-based electrode indices, so catalogue_index = electrode_number - 1. + # This holds for both NP1 (up to 960 electrodes) and NP2 4-shank (up to + # 5120 electrodes, shank-major in the catalogue: s0e0..s0e1279, s1e0..). electrode_to_hwchan = {} for ntrode in sconf: electrode_id = ntrode.attrib["id"] @@ -815,11 +805,20 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: 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) + probe = full_probe.get_slice(active_indices) + + # Clear part-number-specific metadata since we don't know the actual part number. + probe.model_name = "" + probe.description = "" + device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices]) probe.set_device_channel_indices(device_channels) # 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) 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 new file mode 100644 index 00000000..b955c7e2 --- /dev/null +++ b/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec @@ -0,0 +1,1246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Ua'W+ `@p PPpP@ P p@`P  Pp@` `0` 0P0` + `P00`PP`p0`@0P``p00p0P 0@ 00P pPpP0p@p00pp@P0 ``00  @`0 PP@@Ppp 0`p@@@P0p@@@@P pP0PP@p0@ 0pP   @p@pp@ 0@0  @0`00`P`p0P ppP@` P p0` P@ Pp @PP@P0`0 ``U1W+ 0p0@0@ ` +0PP @@ P@@P0  0p p0P`0P @@`p `P p0 @jPp0@``pPPp0`@ pp@P` ` P p@0@@p`@pPP` p@p p@0P0pP@@   `  ``@`00 0 0P`P`P0@`@P@p`P00 `pPP @ PP@`P 0Pp0Ue scheme. + assert 1 <= probe.get_shank_count() <= 4 + assert all(cid.startswith("s") and "e" in cid for cid in probe.contact_ids) + + if __name__ == "__main__": test_parse_meta() test_neuropixels_1_reader() + test_neuropixels_2_4shank_reader() From 1a4f870399fe8e6acb144bdbb8ede163324831a0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:48:54 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ...eGadgets_test_data_NP2_4shank_20260122_header_only.rec | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 c09002dbdcfe91d7020d2e1a19193397f5004450 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 20 Apr 2026 14:11:35 -0600 Subject: [PATCH 4/4] extra docs --- src/probeinterface/io.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index a267205e..3de133fc 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -747,6 +747,12 @@ def write_csv(file, probe): # # NP1 family: NP1000, NP1001, PRB_1_2_0480_2, PRB_1_4_0480_1, PRB_1_4_0480_1_C. # NP2 4-shank family: NP2010, NP2013, NP2014, NP2020, NP2021. + # + # The multi-probe x-shift is the horizontal offset applied to successive + # probes so they do not overlap when plotted. Chosen larger than the probe + # width: NP1 is ~70 um wide (250 um shift leaves a generous gap); NP2 + # 4-shank is ~820 um wide (4 shanks * 250 um shank pitch + ~70 um shank + # width), so 1000 um leaves ~180 um of gap. "neuropixels1": ("NeuroPixels1", "NP1000", 250.0), "neuropixels2": ("NeuroPixels2", "NP2014", 1000.0), } @@ -797,6 +803,11 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: # 0-based electrode indices, so catalogue_index = electrode_number - 1. # This holds for both NP1 (up to 960 electrodes) and NP2 4-shank (up to # 5120 electrodes, shank-major in the catalogue: s0e0..s0e1279, s1e0..). + # + # The probe number is assumed to be a single digit (1, 2, or 3). This + # matches the documented SpikeGadgets limit of three simultaneous + # Neuropixels probes per headstage. If that limit ever changes, the + # id-to-(probe, electrode) split will need to be revisited. electrode_to_hwchan = {} for ntrode in sconf: electrode_id = ntrode.attrib["id"]