Skip to content

Commit c716f06

Browse files
authored
Merge pull request #260 from gkBCCN/spikegadgets_npix
NEW: probe reader for Neuropixels 1.0 in SpikeGadgets .rec file.
2 parents 6556be9 + fdf725d commit c716f06

File tree

4 files changed

+2608
-2
lines changed

4 files changed

+2608
-2
lines changed

src/probeinterface/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
write_imro,
1717
read_BIDS_probe,
1818
write_BIDS_probe,
19+
read_spikegadgets,
1920
read_spikeglx,
2021
parse_spikeglx_meta,
2122
get_saved_channel_indices_from_spikeglx_meta,

src/probeinterface/io.py

+185-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from collections import OrderedDict
2020
from packaging.version import Version, parse
2121
import numpy as np
22+
from xml.etree import ElementTree
2223

2324
from . import __version__
2425
from .probe import Probe
@@ -1263,12 +1264,194 @@ def write_imro(file: str | Path, probe: Probe):
12631264
f.write("".join(ret))
12641265

12651266

1267+
def read_spikegadgets(file: str | Path, raise_error: bool = True) -> ProbeGroup:
1268+
"""
1269+
Find active channels of the given Neuropixels probe from a SpikeGadgets .rec file.
1270+
SpikeGadgets headstages support up to three Neuropixels 1.0 probes (as of March 28, 2024),
1271+
and information for all probes will be returned in a ProbeGroup object.
1272+
1273+
1274+
Parameters
1275+
----------
1276+
file : Path or str
1277+
The .rec file path
1278+
1279+
Returns
1280+
-------
1281+
probe_group : ProbeGroup object
1282+
1283+
"""
1284+
# ------------------------- #
1285+
# Npix 1.0 constants #
1286+
# ------------------------- #
1287+
TOTAL_NPIX_ELECTRODES = 960
1288+
MAX_ACTIVE_CHANNELS = 384
1289+
CONTACT_WIDTH = 16 # um
1290+
CONTACT_HEIGHT = 20 # um
1291+
# ------------------------- #
1292+
1293+
# Read the header and get Configuration elements
1294+
header_txt = parse_spikegadgets_header(file)
1295+
root = ElementTree.fromstring(header_txt)
1296+
hconf = root.find("HardwareConfiguration")
1297+
sconf = root.find("SpikeConfiguration")
1298+
1299+
# Get number of probes present (each has its own Device element)
1300+
probe_configs = [device for device in hconf if device.attrib["name"] == "NeuroPixels1"]
1301+
n_probes = len(probe_configs)
1302+
1303+
if n_probes == 0:
1304+
if raise_error:
1305+
raise Exception("No Neuropixels 1.0 probes found")
1306+
return None
1307+
1308+
# Container to store Probe objects
1309+
probe_group = ProbeGroup()
1310+
1311+
for curr_probe in range(1, n_probes + 1):
1312+
probe_config = probe_configs[curr_probe - 1]
1313+
1314+
# Get number of active channels from probe Device element
1315+
active_channel_str = [option for option in probe_config if option.attrib["name"] == "channelsOn"][0].attrib[
1316+
"data"
1317+
]
1318+
active_channels = [int(ch) for ch in active_channel_str.split(" ") if ch]
1319+
n_active_channels = sum(active_channels)
1320+
assert len(active_channels) == TOTAL_NPIX_ELECTRODES
1321+
assert n_active_channels <= MAX_ACTIVE_CHANNELS
1322+
1323+
"""
1324+
Within the SpikeConfiguration header element (sconf), there is a SpikeNTrode element
1325+
for each electrophysiology channel that contains information relevant to scaling and
1326+
otherwise displaying the information from that channel, as well as the id of the electrode
1327+
from which it is recording ('id').
1328+
1329+
Nested within each SpikeNTrode element is a SpikeChannel element with information about
1330+
the electrode dynamically connected to that channel. This contains information relevant
1331+
for spike sorting, i.e., its spatial location along the probe shank and the hardware channel
1332+
to which it is connected.
1333+
1334+
Excerpt of a sample SpikeConfiguration element:
1335+
1336+
<SpikeConfiguration chanPerChip="1889715760" device="neuropixels1" categories="">
1337+
<SpikeNTrode viewLFPBand="0"
1338+
viewStimBand="0"
1339+
id="1384" # @USE: The first digit is the probe number; the last three digits are the electrode number
1340+
lfpScalingToUv="0.018311105685598315"
1341+
LFPChan="1"
1342+
notchFreq="60"
1343+
rawRefOn="0"
1344+
refChan="1"
1345+
viewSpikeBand="1"
1346+
rawScalingToUv="0.018311105685598315" # For Neuropixels 1.0, raw and spike scaling are identical
1347+
spikeScalingToUv="0.018311105685598315" # Extracted when reading the raw data
1348+
refNTrodeID="1"
1349+
notchBW="10"
1350+
color="#c83200"
1351+
refGroup="2"
1352+
filterOn="1"
1353+
LFPHighFilter="200"
1354+
moduleDataOn="0"
1355+
groupRefOn="0"
1356+
lowFilter="600"
1357+
refOn="0"
1358+
notchFilterOn="0"
1359+
lfpRefOn="0"
1360+
lfpFilterOn="0"
1361+
highFilter="6000"
1362+
>
1363+
<SpikeChannel thresh="60"
1364+
coord_dv="-480" # @USE: dorsal-ventral coordinate in um (in pairs for staggered probe)
1365+
spikeSortingGroup="1782505664"
1366+
triggerOn="1"
1367+
stimCapable="0"
1368+
coord_ml="3192" # @USE: medial-lateral coordinate in um
1369+
coord_ap="3700" # doesn't vary, assuming the shank's flat face is along the ML axis
1370+
maxDisp="400"
1371+
hwChan="735" # @USE: unique device channel that is reading from electrode
1372+
/>
1373+
</SpikeNTrode>
1374+
...
1375+
</SpikeConfiguration>
1376+
"""
1377+
# Find all channels/electrodes that belong to the current probe
1378+
contact_ids = []
1379+
device_channels = []
1380+
positions = np.zeros((n_active_channels, 2))
1381+
1382+
nt_i = 0 # Both probes are in sconf, so need an independent counter of probe electrodes while iterating through
1383+
for ntrode in sconf:
1384+
electrode_id = ntrode.attrib["id"]
1385+
if int(electrode_id[0]) == curr_probe: # first digit of electrode id is probe number
1386+
contact_ids.append(electrode_id)
1387+
positions[nt_i, :] = (ntrode[0].attrib["coord_ml"], ntrode[0].attrib["coord_dv"])
1388+
device_channels.append(ntrode[0].attrib["hwChan"])
1389+
nt_i += 1
1390+
assert len(contact_ids) == n_active_channels
1391+
1392+
# Construct Probe object
1393+
probe = Probe(ndim=2, si_units="um", model_name="Neuropixels 1.0", manufacturer="IMEC")
1394+
probe.set_contacts(
1395+
contact_ids=contact_ids,
1396+
positions=positions,
1397+
shapes="square",
1398+
shank_ids=None,
1399+
shape_params={"width": CONTACT_WIDTH, "height": CONTACT_HEIGHT},
1400+
)
1401+
1402+
# Wire it (i.e., point contact/electrode ids to corresponding hardware/channel ids)
1403+
probe.set_device_channel_indices(device_channels)
1404+
1405+
# Create a nice polygon background when plotting the probes
1406+
x_min = positions[:, 0].min()
1407+
x_max = positions[:, 0].max()
1408+
x_mid = 0.5 * (x_max + x_min)
1409+
y_min = positions[:, 1].min()
1410+
y_max = positions[:, 1].max()
1411+
polygon_default = [
1412+
(x_min - 20, y_min - CONTACT_HEIGHT / 2),
1413+
(x_mid, y_min - 100),
1414+
(x_max + 20, y_min - CONTACT_HEIGHT / 2),
1415+
(x_max + 20, y_max + 20),
1416+
(x_min - 20, y_max + 20),
1417+
]
1418+
probe.set_planar_contour(polygon_default)
1419+
1420+
# If there are multiple probes, they must be shifted such that they don't occupy the same coordinates.
1421+
probe.move([250 * (curr_probe - 1), 0])
1422+
1423+
# Add the probe to the probe container
1424+
probe_group.add_probe(probe)
1425+
1426+
return probe_group
1427+
1428+
1429+
def parse_spikegadgets_header(file: str | Path) -> str:
1430+
"""
1431+
Parse file (SpikeGadgets .rec format) into a string until "</Configuration>",
1432+
which is the last tag of the header, after which the binary data begins.
1433+
"""
1434+
header_size = None
1435+
with open(file, mode="rb") as f:
1436+
while True:
1437+
line = f.readline()
1438+
if b"</Configuration>" in line:
1439+
header_size = f.tell()
1440+
break
1441+
1442+
if header_size is None:
1443+
ValueError("SpikeGadgets: the xml header does not contain '</Configuration>'")
1444+
1445+
f.seek(0)
1446+
return f.read(header_size).decode("utf8")
1447+
1448+
12661449
def read_spikeglx(file: str | Path) -> Probe:
12671450
"""
12681451
Read probe position for the meta file generated by SpikeGLX
12691452
12701453
See http://billkarsh.github.io/SpikeGLX/#metadata-guides for implementation.
1271-
The x_pitch/y_pitch/width are set automatically depending the NP version.
1454+
The x_pitch/y_pitch/width are set automatically depending on the NP version.
12721455
12731456
The shape is auto generated as a shank.
12741457
@@ -1333,7 +1516,7 @@ def read_spikeglx(file: str | Path) -> Probe:
13331516
def parse_spikeglx_meta(meta_file: str | Path) -> dict:
13341517
"""
13351518
Parse the "meta" file from spikeglx into a dict.
1336-
All fiields are kept in txt format and must also parsed themself.
1519+
All fields are kept in txt format and must also be parsed themselves.
13371520
"""
13381521
meta_file = Path(meta_file)
13391522
with meta_file.open(mode="r") as f:

0 commit comments

Comments
 (0)