|
19 | 19 | from collections import OrderedDict
|
20 | 20 | from packaging.version import Version, parse
|
21 | 21 | import numpy as np
|
| 22 | +from xml.etree import ElementTree |
22 | 23 |
|
23 | 24 | from . import __version__
|
24 | 25 | from .probe import Probe
|
@@ -1263,12 +1264,194 @@ def write_imro(file: str | Path, probe: Probe):
|
1263 | 1264 | f.write("".join(ret))
|
1264 | 1265 |
|
1265 | 1266 |
|
| 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 | + |
1266 | 1449 | def read_spikeglx(file: str | Path) -> Probe:
|
1267 | 1450 | """
|
1268 | 1451 | Read probe position for the meta file generated by SpikeGLX
|
1269 | 1452 |
|
1270 | 1453 | 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. |
1272 | 1455 |
|
1273 | 1456 | The shape is auto generated as a shank.
|
1274 | 1457 |
|
@@ -1333,7 +1516,7 @@ def read_spikeglx(file: str | Path) -> Probe:
|
1333 | 1516 | def parse_spikeglx_meta(meta_file: str | Path) -> dict:
|
1334 | 1517 | """
|
1335 | 1518 | 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. |
1337 | 1520 | """
|
1338 | 1521 | meta_file = Path(meta_file)
|
1339 | 1522 | with meta_file.open(mode="r") as f:
|
|
0 commit comments