Skip to content

Commit 5343ee6

Browse files
Merge pull request #3 from VH-Lab/claude/sync-ndr-matlab-updates-LuCFi
Add Neuropixels SpikeGLX reader and format support
2 parents 5534d9e + 45aab91 commit 5343ee6

9 files changed

Lines changed: 939 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Neuropixels SpikeGLX format support.
2+
3+
Port of +ndr/+format/+neuropixelsGLX/
4+
"""
5+
6+
from ndr.format.neuropixelsGLX.header import header
7+
from ndr.format.neuropixelsGLX.probeGeometry import probeGeometry
8+
from ndr.format.neuropixelsGLX.read import read
9+
from ndr.format.neuropixelsGLX.readmeta import readmeta
10+
from ndr.format.neuropixelsGLX.samples2volts import samples2volts
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Parse a SpikeGLX .meta file into a standardized header structure.
2+
3+
Port of +ndr/+format/+neuropixelsGLX/header.m
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from pathlib import Path
9+
from typing import Any
10+
11+
from ndr.format.neuropixelsGLX.readmeta import readmeta
12+
13+
14+
def _parse_channel_subset(subset_str: str, n_saved_chans: int) -> list[int]:
15+
"""Parse the snsSaveChanSubset field.
16+
17+
The field can be 'all' or a comma-separated list of 0-based indices
18+
and ranges (e.g. '0:383,768'). Returns a 1-based channel index list.
19+
"""
20+
if subset_str.strip().lower() == "all":
21+
return list(range(1, n_saved_chans + 1))
22+
23+
chan_list: list[int] = []
24+
parts = subset_str.strip().split(",")
25+
for part in parts:
26+
part = part.strip()
27+
if ":" in part:
28+
range_vals = part.split(":")
29+
start, end = int(range_vals[0]), int(range_vals[1])
30+
chan_list.extend(range(start, end + 1))
31+
else:
32+
chan_list.append(int(part))
33+
34+
# Convert from 0-based to 1-based
35+
return [c + 1 for c in chan_list]
36+
37+
38+
def header(metafilename: str | Path) -> dict[str, Any]:
39+
"""Parse a SpikeGLX .meta file into a standardized header dictionary.
40+
41+
Parameters
42+
----------
43+
metafilename : str or Path
44+
Full path to the .meta file.
45+
46+
Returns
47+
-------
48+
dict
49+
Dictionary with fields:
50+
- sample_rate : float
51+
- n_saved_chans : int
52+
- n_neural_chans : int
53+
- n_sync_chans : int
54+
- saved_chan_list : list of int (1-based)
55+
- voltage_range : tuple of (float, float)
56+
- max_int : int
57+
- bits_per_sample : int
58+
- file_size_bytes : int
59+
- file_time_secs : float
60+
- probe_type : str
61+
- probe_sn : str
62+
- stream_type : str ('ap', 'lf', 'nidq', or 'unknown')
63+
- meta : dict (raw metadata)
64+
"""
65+
metafilename = Path(metafilename)
66+
meta = readmeta(metafilename)
67+
68+
info: dict[str, Any] = {}
69+
info["meta"] = meta
70+
71+
# Sample rate
72+
if "imSampRate" in meta:
73+
info["sample_rate"] = float(meta["imSampRate"])
74+
elif "niSampRate" in meta:
75+
info["sample_rate"] = float(meta["niSampRate"])
76+
else:
77+
raise ValueError("Could not find sample rate in meta file.")
78+
79+
# Number of saved channels
80+
info["n_saved_chans"] = int(meta["nSavedChans"])
81+
82+
# Parse snsApLfSy or snsMnMaXaDw to determine neural vs sync channels
83+
if "snsApLfSy" in meta:
84+
counts = [int(x) for x in meta["snsApLfSy"].split(",")]
85+
# counts[0] = AP chans, counts[1] = LF chans, counts[2] = SY chans
86+
info["n_sync_chans"] = counts[2]
87+
# Determine if this is AP or LF from filename
88+
name = metafilename.stem
89+
if ".lf" in name:
90+
info["stream_type"] = "lf"
91+
info["n_neural_chans"] = counts[1]
92+
else:
93+
info["stream_type"] = "ap"
94+
info["n_neural_chans"] = counts[0]
95+
elif "snsMnMaXaDw" in meta:
96+
info["stream_type"] = "nidq"
97+
counts = [int(x) for x in meta["snsMnMaXaDw"].split(",")]
98+
info["n_neural_chans"] = counts[0]
99+
info["n_sync_chans"] = 0
100+
else:
101+
info["stream_type"] = "unknown"
102+
info["n_neural_chans"] = info["n_saved_chans"] - 1
103+
info["n_sync_chans"] = 1
104+
105+
# Parse saved channel subset
106+
if "snsSaveChanSubset" in meta:
107+
info["saved_chan_list"] = _parse_channel_subset(
108+
meta["snsSaveChanSubset"], info["n_saved_chans"]
109+
)
110+
else:
111+
info["saved_chan_list"] = list(range(1, info["n_saved_chans"] + 1))
112+
113+
# Voltage range
114+
if "imAiRangeMax" in meta:
115+
vmax = float(meta["imAiRangeMax"])
116+
vmin = float(meta["imAiRangeMin"])
117+
info["voltage_range"] = (vmin, vmax)
118+
else:
119+
info["voltage_range"] = (-0.6, 0.6) # Neuropixels 1.0 default
120+
121+
# Max integer value
122+
if "imMaxInt" in meta:
123+
info["max_int"] = int(meta["imMaxInt"])
124+
else:
125+
info["max_int"] = 512 # Neuropixels 1.0 default
126+
127+
# Bits per sample
128+
info["bits_per_sample"] = 16
129+
130+
# File size and duration
131+
info["file_size_bytes"] = int(meta.get("fileSizeBytes", "0"))
132+
info["file_time_secs"] = float(meta.get("fileTimeSecs", "0"))
133+
134+
# Probe information
135+
info["probe_type"] = meta.get("imDatPrb_type", "")
136+
info["probe_sn"] = meta.get("imDatPrb_sn", "")
137+
138+
return info
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""Extract probe geometry from a SpikeGLX .meta file.
2+
3+
Port of +ndr/+format/+neuropixelsGLX/probeGeometry.m
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import re
9+
from pathlib import Path
10+
from typing import Any
11+
12+
import numpy as np
13+
14+
from ndr.format.neuropixelsGLX.header import header
15+
from ndr.format.neuropixelsGLX.readmeta import readmeta
16+
17+
18+
def _find_meta_field(meta: dict[str, str], target: str) -> str:
19+
"""Find a field in the meta dict by partial name match.
20+
21+
Handles the fact that readmeta transforms '~' prefixed field names
22+
(e.g. ~snsGeomMap becomes _snsGeomMap).
23+
"""
24+
for key in meta:
25+
if target.lower() in key.lower():
26+
return meta[key]
27+
return ""
28+
29+
30+
def _parse_snsGeomMap(geommap_str: str, n_neural: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
31+
"""Parse the snsGeomMap string for electrode positions.
32+
33+
Format: (header)(shank:x:y:used)(shank:x:y:used)...
34+
Header: (probePN,nShanks,shankSep,shankWidth) - skipped.
35+
Each channel entry has colon-separated integers: shank:x:y:used.
36+
37+
Returns
38+
-------
39+
x, y, shank : ndarray
40+
Electrode x positions, y (depth) positions, and shank IDs (1-based).
41+
"""
42+
tokens = re.findall(r"\(([^)]+)\)", geommap_str)
43+
44+
if len(tokens) < 1 + n_neural:
45+
raise ValueError(
46+
f"snsGeomMap has {len(tokens)} entries but expected at least "
47+
f"{1 + n_neural} (header + {n_neural} neural)."
48+
)
49+
50+
x = np.zeros(n_neural)
51+
y = np.zeros(n_neural)
52+
shank = np.zeros(n_neural, dtype=int)
53+
54+
# Skip the first token (header), parse channel entries
55+
for i in range(n_neural):
56+
parts = tokens[i + 1].split(":")
57+
if len(parts) < 4:
58+
raise ValueError(f'Could not parse snsGeomMap entry {i}: "{tokens[i + 1]}".')
59+
shank[i] = int(parts[0]) + 1 # Convert 0-based to 1-based
60+
x[i] = float(parts[1])
61+
y[i] = float(parts[2])
62+
63+
return x, y, shank
64+
65+
66+
def _compute_geometry_from_imro(
67+
imroTbl_str: str, probe_type: int
68+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
69+
"""Compute electrode positions from imroTbl.
70+
71+
Supported probe types:
72+
0 - Neuropixels 1.0 (staggered 2-column, 20 um pitch)
73+
21, 2003, 2004 - Neuropixels 2.0 single shank (2-column, 15 um pitch)
74+
24, 2013, 2014 - Neuropixels 2.0 four shank (2-column, 15 um pitch)
75+
76+
Returns
77+
-------
78+
x, y, shank : ndarray
79+
Electrode x positions, y (depth) positions, and shank IDs (1-based).
80+
"""
81+
tokens = re.findall(r"\(([^)]+)\)", imroTbl_str)
82+
if len(tokens) < 2:
83+
raise ValueError("Could not parse imroTbl: too few parenthesized groups.")
84+
85+
n_chans = len(tokens) - 1 # First token is the header
86+
x = np.zeros(n_chans)
87+
y = np.zeros(n_chans)
88+
shank = np.ones(n_chans, dtype=int)
89+
90+
if probe_type == 0:
91+
# Neuropixels 1.0
92+
# Entry format: (chanIdx bank refIdx apGain lfGain apHiPass)
93+
# Electrode ID = chanIdx + bank * 384
94+
# Staggered 2-column layout, 20 um vertical pitch
95+
for i in range(n_chans):
96+
vals = [int(v) for v in tokens[i + 1].replace(",", " ").split()]
97+
elec_id = vals[0] + vals[1] * 384
98+
row = elec_id // 2
99+
col = elec_id % 2
100+
y[i] = row * 20
101+
if row % 2 == 0:
102+
x[i] = 27 + col * 32
103+
else:
104+
x[i] = 11 + col * 32
105+
106+
elif probe_type in (21, 2003, 2004):
107+
# Neuropixels 2.0 single shank
108+
# Entry format: (chanIdx bankMask refIdx elecInd)
109+
# 2-column layout, 15 um vertical pitch
110+
for i in range(n_chans):
111+
vals = [int(v) for v in tokens[i + 1].replace(",", " ").split()]
112+
elec_ind = vals[3]
113+
row = elec_ind // 2
114+
col = elec_ind % 2
115+
y[i] = row * 15
116+
x[i] = 27 + col * 32
117+
118+
elif probe_type in (24, 2013, 2014):
119+
# Neuropixels 2.0 four shank
120+
# Entry format: (chanIdx shankIdx bankMask refIdx elecInd)
121+
# 2-column layout per shank, 15 um pitch, 250 um shank spacing
122+
for i in range(n_chans):
123+
vals = [int(v) for v in tokens[i + 1].replace(",", " ").split()]
124+
shank_idx = vals[1]
125+
elec_ind = vals[4]
126+
row = elec_ind // 2
127+
col = elec_ind % 2
128+
y[i] = row * 15
129+
x[i] = shank_idx * 250 + 27 + col * 32
130+
shank[i] = shank_idx + 1
131+
132+
else:
133+
raise ValueError(
134+
f"Probe type {probe_type} is not supported for geometry computation "
135+
f"from imroTbl. Use a SpikeGLX version that writes ~snsGeomMap "
136+
f"to the .meta file."
137+
)
138+
139+
return x, y, shank
140+
141+
142+
def _probe_type_info(probe_type: int) -> tuple[str, float]:
143+
"""Return probe model name and contact size for a probe type.
144+
145+
Returns
146+
-------
147+
model_name : str
148+
contact_size : float
149+
Contact width/height in micrometers.
150+
"""
151+
if probe_type == 0:
152+
return "Neuropixels 1.0", 12.0
153+
elif probe_type in (21, 2003, 2004):
154+
return "Neuropixels 2.0 Single Shank", 12.0
155+
elif probe_type in (24, 2013, 2014):
156+
return "Neuropixels 2.0 Four Shank", 12.0
157+
elif probe_type in (1100, 1110):
158+
return "Neuropixels Ultra", 6.0
159+
else:
160+
return f"Neuropixels (type {probe_type})", 12.0
161+
162+
163+
def probeGeometry(metafilename: str | Path) -> dict[str, Any]:
164+
"""Extract probe geometry from a SpikeGLX .meta file.
165+
166+
Reads a SpikeGLX .meta file and returns a dictionary whose fields match
167+
the NDI probe_geometry document type. Electrode positions are taken
168+
from the ~snsGeomMap field if present (SpikeGLX >= 20230202), or
169+
computed from the ~imroTbl field and probe type for older files.
170+
171+
Parameters
172+
----------
173+
metafilename : str or Path
174+
Full path to a SpikeGLX .meta file.
175+
176+
Returns
177+
-------
178+
dict
179+
Probe geometry with fields:
180+
- site_locations_leftright : ndarray, [N x 1] left-right positions in um
181+
- site_locations_frontback : ndarray, [N x 1] zeros
182+
- site_locations_depth : ndarray, [N x 1] depth positions in um
183+
- probe_model : str
184+
- manufacturer : str ('IMEC')
185+
- shank_id : ndarray, [N x 1] shank IDs (1-based)
186+
- contact_shape : str ('square')
187+
- contact_shape_width : ndarray, [N x 1] in um
188+
- contact_shape_height : ndarray, [N x 1] in um
189+
- contact_shape_radius : ndarray, [N x 1] zeros
190+
- ndim : int (2)
191+
- unit : str ('um')
192+
- has_planar_contour : int (0)
193+
- contour_x : ndarray (empty)
194+
- contour_y : ndarray (empty)
195+
"""
196+
metafilename = Path(metafilename)
197+
meta = readmeta(metafilename)
198+
info = header(metafilename)
199+
n_neural = info["n_neural_chans"]
200+
201+
# Try snsGeomMap first (available in newer SpikeGLX)
202+
geom_str = _find_meta_field(meta, "snsGeomMap")
203+
204+
if geom_str:
205+
x, y, shank = _parse_snsGeomMap(geom_str, n_neural)
206+
else:
207+
# Fall back to computing geometry from imroTbl + probe type
208+
imro_str = _find_meta_field(meta, "imroTbl")
209+
if not imro_str:
210+
raise ValueError("Meta file has neither snsGeomMap nor imroTbl.")
211+
probe_type_num = int(info["probe_type"]) if info["probe_type"] else 0
212+
x, y, shank = _compute_geometry_from_imro(imro_str, probe_type_num)
213+
214+
# Get probe model name and contact size
215+
probe_type_num = int(info["probe_type"]) if info["probe_type"] else 0
216+
model_name, contact_size = _probe_type_info(probe_type_num)
217+
218+
return {
219+
"site_locations_leftright": x.reshape(-1),
220+
"site_locations_frontback": np.zeros(n_neural),
221+
"site_locations_depth": y.reshape(-1),
222+
"probe_model": model_name,
223+
"manufacturer": "IMEC",
224+
"shank_id": shank.reshape(-1),
225+
"contact_shape": "square",
226+
"contact_shape_width": np.full(n_neural, contact_size),
227+
"contact_shape_height": np.full(n_neural, contact_size),
228+
"contact_shape_radius": np.zeros(n_neural),
229+
"ndim": 2,
230+
"unit": "um",
231+
"has_planar_contour": 0,
232+
"contour_x": np.array([]),
233+
"contour_y": np.array([]),
234+
}

0 commit comments

Comments
 (0)