diff --git a/src/probeinterface/__init__.py b/src/probeinterface/__init__.py index a35ebe0..c9bfe38 100644 --- a/src/probeinterface/__init__.py +++ b/src/probeinterface/__init__.py @@ -16,6 +16,7 @@ write_imro, read_BIDS_probe, write_BIDS_probe, + read_spikegadgets, read_spikeglx, parse_spikeglx_meta, get_saved_channel_indices_from_spikeglx_meta, diff --git a/src/probeinterface/io.py b/src/probeinterface/io.py index 7aa2c5b..0646c75 100644 --- a/src/probeinterface/io.py +++ b/src/probeinterface/io.py @@ -19,6 +19,7 @@ from collections import OrderedDict from packaging.version import Version, parse import numpy as np +from xml.etree import ElementTree from . import __version__ from .probe import Probe @@ -1263,12 +1264,194 @@ def write_imro(file: str | Path, probe: Probe): f.write("".join(ret)) +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), + and information for all probes will be returned in a ProbeGroup object. + + + Parameters + ---------- + file : Path or str + The .rec file path + + Returns + ------- + 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) + 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"] + n_probes = len(probe_configs) + + if n_probes == 0: + if raise_error: + 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 + 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 + 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}, + ) + + # Wire it (i.e., point contact/electrode ids to corresponding hardware/channel ids) + 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. + probe.move([250 * (curr_probe - 1), 0]) + + # Add the probe to the probe container + probe_group.add_probe(probe) + + return probe_group + + +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_spikeglx(file: str | Path) -> Probe: """ Read probe position for the meta file generated by SpikeGLX See http://billkarsh.github.io/SpikeGLX/#metadata-guides for implementation. - The x_pitch/y_pitch/width are set automatically depending the NP version. + The x_pitch/y_pitch/width are set automatically depending on the NP version. The shape is auto generated as a shank. @@ -1333,7 +1516,7 @@ def read_spikeglx(file: str | Path) -> Probe: def parse_spikeglx_meta(meta_file: str | Path) -> dict: """ Parse the "meta" file from spikeglx into a dict. - All fiields are kept in txt format and must also parsed themself. + All fields are kept in txt format and must also be parsed themselves. """ meta_file = Path(meta_file) with meta_file.open(mode="r") as f: diff --git a/tests/data/spikegadgets/SpikeGadgets_test_data_2xNpix1.0_20240318_173658_header_only.rec b/tests/data/spikegadgets/SpikeGadgets_test_data_2xNpix1.0_20240318_173658_header_only.rec new file mode 100644 index 0000000..7ce6737 --- /dev/null +++ b/tests/data/spikegadgets/SpikeGadgets_test_data_2xNpix1.0_20240318_173658_header_only.rec @@ -0,0 +1,2389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_io/test_spikegadgets.py b/tests/test_io/test_spikegadgets.py new file mode 100644 index 0000000..7415db9 --- /dev/null +++ b/tests/test_io/test_spikegadgets.py @@ -0,0 +1,33 @@ +from pathlib import Path +from xml.etree import ElementTree + +import pytest + +from probeinterface import read_spikegadgets +from probeinterface.io import parse_spikegadgets_header + +data_path = Path(__file__).absolute().parent.parent / "data" / "spikegadgets" +test_file = "SpikeGadgets_test_data_2xNpix1.0_20240318_173658_header_only.rec" + + +def test_parse_meta(): + header_txt = parse_spikegadgets_header(data_path / test_file) + root = ElementTree.fromstring(header_txt) + assert root.find("GlobalConfiguration") is not None + assert root.find("HardwareConfiguration") is not None + assert root.find("SpikeConfiguration") is not None + + +def test_neuropixels_1_reader(): + probe_group = read_spikegadgets(data_path / test_file, raise_error=False) + assert len(probe_group.probes) == 2 + for probe in probe_group.probes: + assert "1.0" in probe.model_name + assert probe.get_shank_count() == 1 + assert probe.get_contact_count() == 384 + assert probe_group.get_contact_count() == 768 + + +if __name__ == "__main__": + test_parse_meta() + test_neuropixels_1_reader()