From 38522cc34a0ae30d2bf73c8333a1dcd0e0f80689 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 24 Mar 2026 19:03:02 -0600 Subject: [PATCH 1/8] 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/8] 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/8] [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/8] 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"] From 2d1481d7ccc374ef850fcd74c2d66feaa3ea08ec Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 12 May 2026 18:05:29 -0600 Subject: [PATCH 5/8] Drop NP2.0 support from this PR Reverts the NP2.0 4-shank additions so this PR is a pure NP1.0 catalogue migration. NP2.0 support will land as a separate follow-up, which keeps the dispatch dict and unknown-device guard out of the migration PR. --- .gitignore | 1 - src/probeinterface/io.py | 66 +- ...t_data_NP2_4shank_20260122_header_only.rec | 1246 ----------------- tests/test_io/test_spikegadgets.py | 18 - 4 files changed, 20 insertions(+), 1311 deletions(-) delete mode 100644 tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec diff --git a/.gitignore b/.gitignore index 52a45ce4..0ee5de65 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,3 @@ uv.lock # libraries **/neuropixels_library_generated **/cambridgeneurotech_library -.codex diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 3de133fc..602e1a56 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -733,35 +733,10 @@ 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. - # - # 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), -} - - 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 probes (1.0 or 2.0), + 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. @@ -775,39 +750,38 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: 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") - # 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] + probe_configs = [d for d in hconf if d.attrib.get("name") == "NeuroPixels1"] n_probes = len(probe_configs) if n_probes == 0: if raise_error: - raise Exception(f"No {hc_device_name} devices found in SpikeGadgets .rec header") + raise Exception("No Neuropixels 1.0 probes found") return None 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 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..). - # - # 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. + # 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. electrode_to_hwchan = {} for ntrode in sconf: electrode_id = ntrode.attrib["id"] @@ -818,7 +792,7 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: 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. @@ -829,7 +803,7 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: probe.set_device_channel_indices(device_channels) # Shift multiple probes so they don't overlap when plotted - probe.move([multi_probe_x_shift_um * (curr_probe - 1), 0]) + probe.move([250 * (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 deleted file mode 100644 index 07ebe2e3..00000000 --- a/tests/data/spikegadgets/SpikeGadgets_test_data_NP2_4shank_20260122_header_only.rec +++ /dev/null @@ -1,1246 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -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 47b76eee45fa3c86daacf2b37fd2e78ed3745ae5 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 12 May 2026 18:11:43 -0600 Subject: [PATCH 6/8] Restore .codex to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From df0d17e0c4052c94630122e1ced336999d401ba8 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 14 May 2026 10:48:01 -0600 Subject: [PATCH 7/8] Annotate gains, adc_range_vpp, and ADC mux info on Neuropixels probes build_neuropixels_probe annotates adc_range_vpp and, when the catalogue lists a single fixed value (NP2.x), ap_gain/lf_gain. read_imro, read_spikeglx, and _annotate_openephys_probe fall back to source metadata for the scalar gains when the catalogue did not provide them. read_spikegadgets parses APGainMode/LFPGainMode from the SourceOptions block and calls _annotate_probe_with_adc_sampling_info, bringing it to functional parity with the other Neuropixels readers for downstream preprocessing. --- src/probeinterface/io.py | 26 ++++++++++++- src/probeinterface/neuropixels_tools.py | 49 ++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 602e1a56..286c5787 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -21,7 +21,7 @@ from . import __version__ from .probe import Probe from .probegroup import ProbeGroup -from .neuropixels_tools import build_neuropixels_probe +from .neuropixels_tools import build_neuropixels_probe, _annotate_probe_with_adc_sampling_info from .utils import import_safely @@ -773,6 +773,10 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: raise Exception("No Neuropixels 1.0 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"] + probe_group = ProbeGroup() for curr_probe in range(1, n_probes + 1): @@ -802,6 +806,26 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup: device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices]) probe.set_device_channel_indices(device_channels) + # 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) + + # NP1.0 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([250 * (curr_probe - 1), 0]) diff --git a/src/probeinterface/neuropixels_tools.py b/src/probeinterface/neuropixels_tools.py index b2ddf0ef..3b162457 100644 --- a/src/probeinterface/neuropixels_tools.py +++ b/src/probeinterface/neuropixels_tools.py @@ -250,12 +250,25 @@ def build_neuropixels_probe(probe_part_number: str) -> Probe: probe.annotate(shank_tips=shank_tips) # ===== 7. Add metadata annotations ===== + lf_sampling_frequency_hz = float(probe_spec_dict["lf_sample_frequency_hz"]) + adc_range_vpp = float(probe_spec_dict["adc_range_vpp"]) + ap_gain_list = [float(gain) for gain in probe_spec_dict["ap_gain_list"].split(",")] probe.annotate( adc_bit_depth=int(probe_spec_dict["adc_bit_depth"]), num_readout_channels=int(probe_spec_dict["num_readout_channels"]), ap_sample_frequency_hz=float(probe_spec_dict["ap_sample_frequency_hz"]), - lf_sample_frequency_hz=float(probe_spec_dict["lf_sample_frequency_hz"]), + lf_sample_frequency_hz=lf_sampling_frequency_hz, + adc_range_vpp=adc_range_vpp, ) + # If the catalogue lists a single AP gain value, annotate it directly because it is + # not user-configurable (NP2.x case). NP1.0 has multiple selectable gains, so the + # recording-time value must come from the reader. + if len(ap_gain_list) == 1: + probe.annotate(ap_gain=ap_gain_list[0]) + if lf_sampling_frequency_hz > 0: + lf_gain_list = [float(gain) for gain in probe_spec_dict["lf_gain_list"].split(",")] + if len(lf_gain_list) == 1: + probe.annotate(lf_gain=lf_gain_list[0]) # ===== 8. Store ADC sampling table ===== # The ADC sampling table describes how readout channels map to ADCs, not electrodes. @@ -751,6 +764,16 @@ def read_imro(file_path: str | Path) -> Probe: vector_properties_available[imro_field_to_pi_field.get(k)] = v probe.annotate_contacts(**vector_properties_available) + # Annotate scalar ap_gain/lf_gain from per-channel IMRO data when not already set + # by build_neuropixels_probe (NP1.0 has programmable gains; NP2.x is fixed). + if "ap_gain" not in probe.annotations: + ap_gain_values = imro_per_channel.get("ap_gain", []) + if len(ap_gain_values) > 0: + probe.annotate(ap_gain=float(ap_gain_values[0])) + lf_gain_values = imro_per_channel.get("lf_gain", []) + if len(lf_gain_values) > 0: + probe.annotate(lf_gain=float(lf_gain_values[0])) + return probe @@ -823,6 +846,16 @@ def read_spikeglx(file: str | Path) -> Probe: annotations[pi_field] = values probe.annotate_contacts(**annotations) + # Annotate scalar ap_gain/lf_gain from per-channel IMRO data when not already set + # by build_neuropixels_probe (NP1.0 has programmable gains; NP2.x is fixed). + if "ap_gain" not in probe.annotations: + ap_gain_values = imro_per_channel.get("ap_gain", []) + if len(ap_gain_values) > 0: + probe.annotate(ap_gain=float(ap_gain_values[0])) + lf_gain_values = imro_per_channel.get("lf_gain", []) + if len(lf_gain_values) > 0: + probe.annotate(lf_gain=float(lf_gain_values[0])) + # ===== 5b. Add ADC sampling annotations ===== # The ADC sampling table describes which ADC samples each readout channel and in what order. # At this point, contacts are ordered by readout channel (0-383), so we can directly @@ -1138,6 +1171,8 @@ def _parse_openephys_settings( slot = np_probe.attrib.get("slot") port = np_probe.attrib.get("port") dock = np_probe.attrib.get("dock") + ap_gain_value = np_probe.attrib.get("apGainValue") + lf_gain_value = np_probe.attrib.get("lfpGainValue") probe_part_number = np_probe.attrib.get("probe_part_number") or np_probe.attrib.get("probePartNumber") probe_serial_number = np_probe.attrib.get("probe_serial_number") or np_probe.attrib.get("probeSerialNumber") selected_electrodes = np_probe.find("SELECTED_ELECTRODES") @@ -1170,6 +1205,8 @@ def _parse_openephys_settings( "slot": slot, "port": port, "dock": dock, + "ap_gain": ap_gain_value, + "lf_gain": lf_gain_value, "pt_metadata": pt_metadata, "selected_electrode_indices": None, "contact_ids": None, @@ -1468,6 +1505,16 @@ def _annotate_openephys_probe(probe: Probe, probe_info: dict) -> None: adc_sampling_table = probe.annotations.get("adc_sampling_table") _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) + # Annotate scalar ap_gain/lf_gain from settings.xml when not already set by build_neuropixels_probe. + # Open Ephys stores them as "{value}x" strings, e.g. "500x". + if "ap_gain" not in probe.annotations: + ap_gain_str = probe_info.get("ap_gain") + if ap_gain_str is not None: + probe.annotate(ap_gain=float(ap_gain_str.rstrip("x"))) + lf_gain_str = probe_info.get("lf_gain") + if lf_gain_str is not None: + probe.annotate(lf_gain=float(lf_gain_str.rstrip("x"))) + def read_openephys_neuropixels( settings_file: str | Path, From 5f4b0c8c565149ffc0261554e8df931796ce4459 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 14 May 2026 10:53:04 -0600 Subject: [PATCH 8/8] Match origin/main wording in overlapping gain annotations --- src/probeinterface/neuropixels_tools.py | 40 +++++++------------------ 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/src/probeinterface/neuropixels_tools.py b/src/probeinterface/neuropixels_tools.py index 2d9978e1..794677c7 100644 --- a/src/probeinterface/neuropixels_tools.py +++ b/src/probeinterface/neuropixels_tools.py @@ -260,9 +260,7 @@ def build_neuropixels_probe(probe_part_number: str) -> Probe: lf_sample_frequency_hz=lf_sampling_frequency_hz, adc_range_vpp=adc_range_vpp, ) - # If the catalogue lists a single AP gain value, annotate it directly because it is - # not user-configurable (NP2.x case). NP1.0 has multiple selectable gains, so the - # recording-time value must come from the reader. + # If there is only one AP gain value, annotate with gains directly since it cannot be changed. if len(ap_gain_list) == 1: probe.annotate(ap_gain=ap_gain_list[0]) if lf_sampling_frequency_hz > 0: @@ -764,16 +762,6 @@ def read_imro(file_path: str | Path) -> Probe: vector_properties_available[imro_field_to_pi_field.get(k)] = v probe.annotate_contacts(**vector_properties_available) - # Annotate scalar ap_gain/lf_gain from per-channel IMRO data when not already set - # by build_neuropixels_probe (NP1.0 has programmable gains; NP2.x is fixed). - if "ap_gain" not in probe.annotations: - ap_gain_values = imro_per_channel.get("ap_gain", []) - if len(ap_gain_values) > 0: - probe.annotate(ap_gain=float(ap_gain_values[0])) - lf_gain_values = imro_per_channel.get("lf_gain", []) - if len(lf_gain_values) > 0: - probe.annotate(lf_gain=float(lf_gain_values[0])) - return probe @@ -846,16 +834,6 @@ def read_spikeglx(file: str | Path) -> Probe: annotations[pi_field] = values probe.annotate_contacts(**annotations) - # Annotate scalar ap_gain/lf_gain from per-channel IMRO data when not already set - # by build_neuropixels_probe (NP1.0 has programmable gains; NP2.x is fixed). - if "ap_gain" not in probe.annotations: - ap_gain_values = imro_per_channel.get("ap_gain", []) - if len(ap_gain_values) > 0: - probe.annotate(ap_gain=float(ap_gain_values[0])) - lf_gain_values = imro_per_channel.get("lf_gain", []) - if len(lf_gain_values) > 0: - probe.annotate(lf_gain=float(lf_gain_values[0])) - # ===== 5b. Add ADC sampling annotations ===== # The ADC sampling table describes which ADC samples each readout channel and in what order. # At this point, contacts are ordered by readout channel (0-383), so we can directly @@ -1192,6 +1170,7 @@ def _parse_openephys_settings( dock = np_probe.attrib.get("dock") ap_gain_value = np_probe.attrib.get("apGainValue") lf_gain_value = np_probe.attrib.get("lfpGainValue") + probe_part_number = np_probe.attrib.get("probe_part_number") or np_probe.attrib.get("probePartNumber") probe_serial_number = np_probe.attrib.get("probe_serial_number") or np_probe.attrib.get("probeSerialNumber") selected_electrodes = np_probe.find("SELECTED_ELECTRODES") @@ -1224,8 +1203,6 @@ def _parse_openephys_settings( "slot": slot, "port": port, "dock": dock, - "ap_gain": ap_gain_value, - "lf_gain": lf_gain_value, "pt_metadata": pt_metadata, "selected_electrode_indices": None, "contact_ids": None, @@ -1527,15 +1504,18 @@ def _annotate_openephys_probe(probe: Probe, probe_info: dict) -> None: adc_sampling_table = probe.annotations.get("adc_sampling_table") _annotate_probe_with_adc_sampling_info(probe, adc_sampling_table) - # Annotate scalar ap_gain/lf_gain from settings.xml when not already set by build_neuropixels_probe. - # Open Ephys stores them as "{value}x" strings, e.g. "500x". + # Update gain values from settings if not already annotated from table if "ap_gain" not in probe.annotations: ap_gain_str = probe_info.get("ap_gain") - if ap_gain_str is not None: - probe.annotate(ap_gain=float(ap_gain_str.rstrip("x"))) lf_gain_str = probe_info.get("lf_gain") + if ap_gain_str is not None: + # ap_gain_str is formatted as "{gain}x", e.g. "500x" + ap_gain = float(ap_gain_str[:-1]) + probe.annotate(ap_gain=ap_gain) if lf_gain_str is not None: - probe.annotate(lf_gain=float(lf_gain_str.rstrip("x"))) + # lf_gain_str is formatted as "{gain}x", e.g. "250x" + lf_gain = float(lf_gain_str[:-1]) + probe.annotate(lf_gain=lf_gain) def read_openephys_neuropixels(