Skip to content

Commit 38522cc

Browse files
committed
Add probe catalogue path to spikegadgets
1 parent 2789811 commit 38522cc

2 files changed

Lines changed: 43 additions & 111 deletions

File tree

src/probeinterface/io.py

Lines changed: 42 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from . import __version__
2222
from .probe import Probe
2323
from .probegroup import ProbeGroup
24+
from .neuropixels_tools import build_neuropixels_probe
2425
from .utils import import_safely
2526

2627

@@ -749,15 +750,6 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup:
749750
probe_group : ProbeGroup object
750751
751752
"""
752-
# ------------------------- #
753-
# Npix 1.0 constants #
754-
# ------------------------- #
755-
TOTAL_NPIX_ELECTRODES = 960
756-
MAX_ACTIVE_CHANNELS = 384
757-
CONTACT_WIDTH = 16 # um
758-
CONTACT_HEIGHT = 20 # um
759-
# ------------------------- #
760-
761753
# Read the header and get Configuration elements
762754
header_txt = parse_spikegadgets_header(file)
763755
root = ElementTree.fromstring(header_txt)
@@ -773,122 +765,62 @@ def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup:
773765
raise Exception("No Neuropixels 1.0 probes found")
774766
return None
775767

776-
# Container to store Probe objects
777768
probe_group = ProbeGroup()
778769

779770
for curr_probe in range(1, n_probes + 1):
780771
probe_config = probe_configs[curr_probe - 1]
781772

782-
# Get number of active channels from probe Device element
773+
# Get active electrode indices from the channelsOn bitmask (960 elements, 0-indexed)
783774
active_channel_str = [option for option in probe_config if option.attrib["name"] == "channelsOn"][0].attrib[
784775
"data"
785776
]
786-
active_channels = [int(ch) for ch in active_channel_str.split(" ") if ch]
787-
n_active_channels = sum(active_channels)
788-
assert len(active_channels) == TOTAL_NPIX_ELECTRODES
789-
assert n_active_channels <= MAX_ACTIVE_CHANNELS
790-
791-
"""
792-
Within the SpikeConfiguration header element (sconf), there is a SpikeNTrode element
793-
for each electrophysiology channel that contains information relevant to scaling and
794-
otherwise displaying the information from that channel, as well as the id of the electrode
795-
from which it is recording ('id').
796-
797-
Nested within each SpikeNTrode element is a SpikeChannel element with information about
798-
the electrode dynamically connected to that channel. This contains information relevant
799-
for spike sorting, i.e., its spatial location along the probe shank and the hardware channel
800-
to which it is connected.
801-
802-
Excerpt of a sample SpikeConfiguration element:
803-
804-
<SpikeConfiguration chanPerChip="1889715760" device="neuropixels1" categories="">
805-
<SpikeNTrode viewLFPBand="0"
806-
viewStimBand="0"
807-
id="1384" # @USE: The first digit is the probe number; the last three digits are the electrode number
808-
lfpScalingToUv="0.018311105685598315"
809-
LFPChan="1"
810-
notchFreq="60"
811-
rawRefOn="0"
812-
refChan="1"
813-
viewSpikeBand="1"
814-
rawScalingToUv="0.018311105685598315" # For Neuropixels 1.0, raw and spike scaling are identical
815-
spikeScalingToUv="0.018311105685598315" # Extracted when reading the raw data
816-
refNTrodeID="1"
817-
notchBW="10"
818-
color="#c83200"
819-
refGroup="2"
820-
filterOn="1"
821-
LFPHighFilter="200"
822-
moduleDataOn="0"
823-
groupRefOn="0"
824-
lowFilter="600"
825-
refOn="0"
826-
notchFilterOn="0"
827-
lfpRefOn="0"
828-
lfpFilterOn="0"
829-
highFilter="6000"
830-
>
831-
<SpikeChannel thresh="60"
832-
coord_dv="-480" # @USE: dorsal-ventral coordinate in um (in pairs for staggered probe)
833-
spikeSortingGroup="1782505664"
834-
triggerOn="1"
835-
stimCapable="0"
836-
coord_ml="3192" # @USE: medial-lateral coordinate in um
837-
coord_ap="3700" # doesn't vary, assuming the shank's flat face is along the ML axis
838-
maxDisp="400"
839-
hwChan="735" # @USE: unique device channel that is reading from electrode
840-
/>
841-
</SpikeNTrode>
842-
...
843-
</SpikeConfiguration>
844-
"""
845-
# Find all channels/electrodes that belong to the current probe
846-
contact_ids = []
847-
device_channels = []
848-
positions = np.zeros((n_active_channels, 2))
849-
850-
nt_i = 0 # Both probes are in sconf, so need an independent counter of probe electrodes while iterating through
777+
channels_on = np.array([int(ch) for ch in active_channel_str.split(" ") if ch])
778+
active_indices = np.nonzero(channels_on)[0]
779+
780+
# Build full catalogue probe and slice to active electrodes.
781+
#
782+
# The SpikeGadgets XML format does not include the probe part number, so we
783+
# hardcode "NP1000" (standard Neuropixels 1.0). This is safe because the
784+
# Bennu manual (Rev3, 2025) explicitly states support for "Neuropixels 1.0
785+
# probes (every version except NHP) OR Neuropixels 2.0". The supported
786+
# NP 1.0 variants (NP1000, NP1001, PRB_1_2_0480_2, PRB_1_4_0480_1,
787+
# PRB_1_4_0480_1_C) share identical 2D geometry in the catalogue: same
788+
# contact positions, pitch, stagger, shank width, tip length, shank length,
789+
# contour, electrode count, and ADC/MUX tables. The only fields that differ
790+
# are metadata (description, datasheet, is_commercial) and shank_thickness_um
791+
# (Z-axis), none of which probeinterface uses.
792+
#
793+
# NP 2.0 support: the Bennu uses a different cone and firmware for 2.0
794+
# probes, and the workspace creation step distinguishes "Neuropixels 1.0"
795+
# from "Neuropixels 2.0". When .rec files from NP 2.0 recordings become
796+
# available, this reader will need to detect the probe type (likely from
797+
# the device name in the XML) and call build_neuropixels_probe with the
798+
# appropriate part number.
799+
full_probe = build_neuropixels_probe("NP1000")
800+
probe = full_probe.get_slice(active_indices)
801+
802+
# Clear part-number-specific metadata since we don't know the actual part number.
803+
probe.model_name = ""
804+
probe.description = ""
805+
806+
# Parse SpikeNTrode elements to build the device channel mapping.
807+
# Each SpikeNTrode has an id like "1384" where the first digit is the probe number
808+
# and the remaining digits are the 1-based electrode number. The catalogue uses
809+
# 0-based electrode indices, so catalogue_index = electrode_number - 1.
810+
electrode_to_hwchan = {}
851811
for ntrode in sconf:
852812
electrode_id = ntrode.attrib["id"]
853-
if int(electrode_id[0]) == curr_probe: # first digit of electrode id is probe number
854-
contact_ids.append(electrode_id)
855-
positions[nt_i, :] = (ntrode[0].attrib["coord_ml"], ntrode[0].attrib["coord_dv"])
856-
device_channels.append(ntrode[0].attrib["hwChan"])
857-
nt_i += 1
858-
assert len(contact_ids) == n_active_channels
859-
860-
# Construct Probe object
861-
probe = Probe(ndim=2, si_units="um", model_name="Neuropixels 1.0", manufacturer="IMEC")
862-
probe.set_contacts(
863-
contact_ids=contact_ids,
864-
positions=positions,
865-
shapes="square",
866-
shank_ids=None,
867-
shape_params={"width": CONTACT_WIDTH, "height": CONTACT_HEIGHT},
868-
)
813+
if int(electrode_id[0]) == curr_probe:
814+
catalogue_index = int(electrode_id[1:]) - 1
815+
hw_chan = int(ntrode[0].attrib["hwChan"])
816+
electrode_to_hwchan[catalogue_index] = hw_chan
869817

870-
# Wire it (i.e., point contact/electrode ids to corresponding hardware/channel ids)
818+
device_channels = np.array([electrode_to_hwchan[idx] for idx in active_indices])
871819
probe.set_device_channel_indices(device_channels)
872820

873-
# Create a nice polygon background when plotting the probes
874-
x_min = positions[:, 0].min()
875-
x_max = positions[:, 0].max()
876-
x_mid = 0.5 * (x_max + x_min)
877-
y_min = positions[:, 1].min()
878-
y_max = positions[:, 1].max()
879-
polygon_default = [
880-
(x_min - 20, y_min - CONTACT_HEIGHT / 2),
881-
(x_mid, y_min - 100),
882-
(x_max + 20, y_min - CONTACT_HEIGHT / 2),
883-
(x_max + 20, y_max + 20),
884-
(x_min - 20, y_max + 20),
885-
]
886-
probe.set_planar_contour(polygon_default)
887-
888-
# If there are multiple probes, they must be shifted such that they don't occupy the same coordinates.
821+
# Shift multiple probes so they don't overlap when plotted
889822
probe.move([250 * (curr_probe - 1), 0])
890823

891-
# Add the probe to the probe container
892824
probe_group.add_probe(probe)
893825

894826
return probe_group

tests/test_io/test_spikegadgets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_neuropixels_1_reader():
2323
for probe in probe_group.probes:
2424
probe_dict = probe.to_dict(array_as_list=True)
2525
validate_probe_dict(probe_dict)
26-
assert "1.0" in probe.model_name
26+
assert probe.model_name == ""
2727
assert probe.get_shank_count() == 1
2828
assert probe.get_contact_count() == 384
2929
assert probe_group.get_contact_count() == 768

0 commit comments

Comments
 (0)