Skip to content

Commit 2c1583c

Browse files
authored
Merge branch 'master' into add_intan_stim
2 parents 42d56fd + b26be18 commit 2c1583c

File tree

7 files changed

+259
-32
lines changed

7 files changed

+259
-32
lines changed

neo/rawio/baserawio.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,184 @@ def __repr__(self):
239239

240240
return txt
241241

242+
def _repr_html_(self):
243+
"""
244+
HTML representation for the raw recording base.
245+
246+
Returns
247+
-------
248+
html : str
249+
The HTML representation as a string.
250+
"""
251+
html = []
252+
html.append('<div style="font-family: Arial, sans-serif; max-width: 1000px; margin: 0 auto;">')
253+
254+
# Header
255+
html.append(f'<h3 style="color: #2c3e50;">{self.__class__.__name__}: {self.source_name()}</h3>')
256+
257+
if self.is_header_parsed:
258+
# Basic info
259+
nb_block = self.block_count()
260+
html.append(f"<p><strong>nb_block:</strong> {nb_block}</p>")
261+
nb_seg = [self.segment_count(i) for i in range(nb_block)]
262+
html.append(f"<p><strong>nb_segment:</strong> {nb_seg}</p>")
263+
264+
# CSS for tables - using only black, white, and gray colors
265+
html.append(
266+
"""
267+
<style>
268+
#{unique_id} table.neo-table {{
269+
border-collapse: collapse;
270+
width: 100%;
271+
margin-bottom: 20px;
272+
font-size: 14px;
273+
color: inherit;
274+
background-color: transparent;
275+
}}
276+
#{unique_id} table.neo-table th,
277+
#{unique_id} table.neo-table td {{
278+
border: 1px solid #888;
279+
padding: 8px;
280+
text-align: left;
281+
}}
282+
#{unique_id} table.neo-table th {{
283+
background-color: rgba(128,128,128,0.2);
284+
}}
285+
#{unique_id} table.neo-table tr:nth-child(even) {{
286+
background-color: rgba(128,128,128,0.1);
287+
}}
288+
#{unique_id} details {{
289+
margin-bottom: 15px;
290+
border: 1px solid rgba(128,128,128,0.3);
291+
border-radius: 4px;
292+
overflow: hidden;
293+
background-color: transparent;
294+
}}
295+
#{unique_id} summary {{
296+
padding: 10px;
297+
background-color: rgba(128,128,128,0.2);
298+
cursor: pointer;
299+
font-weight: bold;
300+
color: inherit;
301+
}}
302+
#{unique_id} details[open] summary {{
303+
border-bottom: 1px solid rgba(128,128,128,0.3);
304+
}}
305+
#{unique_id} .table-container {{
306+
padding: 10px;
307+
overflow-x: auto;
308+
background-color: transparent;
309+
}}
310+
</style>
311+
"""
312+
)
313+
314+
# Signal Streams
315+
signal_streams = self.header["signal_streams"]
316+
if signal_streams.size > 0:
317+
html.append("<details>")
318+
html.append("<summary>Signal Streams</summary>")
319+
html.append('<div class="table-container">')
320+
html.append('<table class="neo-table">')
321+
html.append("<thead><tr><th>Name</th><th>ID</th><th>Buffer ID</th><th>Channel Count</th></tr></thead>")
322+
html.append("<tbody>")
323+
324+
for i, stream in enumerate(signal_streams):
325+
html.append("<tr>")
326+
html.append(f'<td>{stream["name"]}</td>')
327+
html.append(f'<td>{stream["id"]}</td>')
328+
html.append(f'<td>{stream["buffer_id"]}</td>')
329+
html.append(f"<td>{self.signal_channels_count(i)}</td>")
330+
html.append("</tr>")
331+
332+
html.append("</tbody></table>")
333+
html.append("</div>")
334+
html.append("</details>")
335+
336+
# Signal Channels
337+
signal_channels = self.header["signal_channels"]
338+
if signal_channels.size > 0:
339+
html.append("<details>")
340+
html.append("<summary>Signal Channels</summary>")
341+
html.append('<div class="table-container">')
342+
html.append('<table class="neo-table">')
343+
html.append(
344+
"<thead><tr><th>Name</th><th>ID</th><th>Sampling Rate</th><th>Data Type</th><th>Units</th><th>Gain</th><th>Offset</th><th>Stream ID</th><th>Buffer ID</th></tr></thead>"
345+
)
346+
html.append("<tbody>")
347+
348+
for channel in signal_channels:
349+
html.append("<tr>")
350+
html.append(f'<td>{channel["name"]}</td>')
351+
html.append(f'<td>{channel["id"]}</td>')
352+
html.append(f'<td>{channel["sampling_rate"]}</td>')
353+
html.append(f'<td>{channel["dtype"]}</td>')
354+
html.append(f'<td>{channel["units"]}</td>')
355+
html.append(f'<td>{channel["gain"]}</td>')
356+
html.append(f'<td>{channel["offset"]}</td>')
357+
html.append(f'<td>{channel["stream_id"]}</td>')
358+
html.append(f'<td>{channel["buffer_id"]}</td>')
359+
html.append("</tr>")
360+
361+
html.append("</tbody></table>")
362+
html.append("</div>")
363+
html.append("</details>")
364+
365+
# Spike Channels
366+
spike_channels = self.header["spike_channels"]
367+
if spike_channels.size > 0:
368+
html.append("<details>")
369+
html.append("<summary>Spike Channels</summary>")
370+
html.append('<div class="table-container">')
371+
html.append('<table class="neo-table">')
372+
html.append(
373+
"<thead><tr><th>Name</th><th>ID</th><th>WF Units</th><th>WF Gain</th><th>WF Offset</th><th>WF Left Sweep</th><th>WF Sampling Rate</th></tr></thead>"
374+
)
375+
html.append("<tbody>")
376+
377+
for channel in spike_channels:
378+
html.append("<tr>")
379+
html.append(f'<td>{channel["name"]}</td>')
380+
html.append(f'<td>{channel["id"]}</td>')
381+
html.append(f'<td>{channel["wf_units"]}</td>')
382+
html.append(f'<td>{channel["wf_gain"]}</td>')
383+
html.append(f'<td>{channel["wf_offset"]}</td>')
384+
html.append(f'<td>{channel["wf_left_sweep"]}</td>')
385+
html.append(f'<td>{channel["wf_sampling_rate"]}</td>')
386+
html.append("</tr>")
387+
388+
html.append("</tbody></table>")
389+
html.append("</div>")
390+
html.append("</details>")
391+
392+
# Event Channels
393+
event_channels = self.header["event_channels"]
394+
if event_channels.size > 0:
395+
html.append("<details>")
396+
html.append("<summary>Event Channels</summary>")
397+
html.append('<div class="table-container">')
398+
html.append('<table class="neo-table">')
399+
html.append("<thead><tr><th>Name</th><th>ID</th><th>Type</th></tr></thead>")
400+
html.append("<tbody>")
401+
402+
for channel in event_channels:
403+
html.append("<tr>")
404+
html.append(f'<td>{channel["name"]}</td>')
405+
html.append(f'<td>{channel["id"]}</td>')
406+
html.append(
407+
f'<td>{channel["type"].decode("utf-8") if isinstance(channel["type"], bytes) else channel["type"]}</td>'
408+
)
409+
html.append("</tr>")
410+
411+
html.append("</tbody></table>")
412+
html.append("</div>")
413+
html.append("</details>")
414+
else:
415+
html.append("<p><em>Call <code>parse_header()</code> to load the reader data.</p>")
416+
417+
html.append("</div>")
418+
return "\n".join(html)
419+
242420
def _generate_minimal_annotations(self):
243421
"""
244422
Helper function that generates a nested dict for annotations.

neo/rawio/blackrockrawio.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,19 @@ def _parse_header(self):
276276
self.internal_unit_ids = [] # pair of chan['packet_id'], spikes['unit_class_nb']
277277
for i in range(len(self.__nev_ext_header[b"NEUEVWAV"])):
278278

279-
channel_id = self.__nev_ext_header[b"NEUEVWAV"]["electrode_id"][i]
279+
# electrode_id values are stored at uint16 which can overflow when
280+
# multiplying by 1000 below. We convert to a regular python int which
281+
# won't overflow
282+
channel_id = int(self.__nev_ext_header[b"NEUEVWAV"]["electrode_id"][i])
280283

281284
chan_mask = spikes["packet_id"] == channel_id
282285
chan_spikes = spikes[chan_mask]
286+
287+
# all `unit_class_nb` is uint8. Also will have issues with overflow
288+
# cast this to python int
283289
all_unit_id = np.unique(chan_spikes["unit_class_nb"])
284290
for u, unit_id in enumerate(all_unit_id):
291+
unit_id = int(unit_id)
285292
self.internal_unit_ids.append((channel_id, unit_id))
286293
name = f"ch{channel_id}#{unit_id}"
287294
_id = f"Unit {1000 * channel_id + unit_id}"

neo/rawio/spikegadgetsrawio.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def __init__(self, filename="", selected_streams=None):
8181
def _source_name(self):
8282
return self.filename
8383

84-
def _produce_ephys_channel_ids(self, n_total_channels, n_channels_per_chip):
84+
def _produce_ephys_channel_ids(self, n_total_channels, n_channels_per_chip, missing_hw_chans):
8585
"""Compute the channel ID labels for subset of spikegadgets recordings
8686
The ephys channels in the .rec file are stored in the following order:
8787
hwChan ID of channel 0 of first chip, hwChan ID of channel 0 of second chip, ..., hwChan ID of channel 0 of Nth chip,
@@ -94,14 +94,18 @@ def _produce_ephys_channel_ids(self, n_total_channels, n_channels_per_chip):
9494
This doesn't work for all types of spikegadgets
9595
see: https://github.com/NeuralEnsemble/python-neo/issues/1517
9696
97+
If there are any missing hardware channels, they must be specified in missing_hw_chans.
98+
See: https://github.com/NeuralEnsemble/python-neo/issues/1592
9799
"""
98100
ephys_channel_ids_list = []
99-
for hw_channel in range(n_channels_per_chip):
100-
hw_channel_list = [
101-
hw_channel + chip * n_channels_per_chip for chip in range(int(n_total_channels / n_channels_per_chip))
102-
]
103-
ephys_channel_ids_list.append(hw_channel_list)
104-
return [channel for channel_list in ephys_channel_ids_list for channel in channel_list]
101+
for local_hw_channel in range(n_channels_per_chip):
102+
n_chips = int(n_total_channels / n_channels_per_chip)
103+
for chip in range(n_chips):
104+
global_hw_chan = local_hw_channel + chip * n_channels_per_chip
105+
if global_hw_chan in missing_hw_chans:
106+
continue
107+
ephys_channel_ids_list.append(local_hw_channel + chip * n_channels_per_chip)
108+
return ephys_channel_ids_list
105109

106110
def _parse_header(self):
107111
# parse file until "</Configuration>"
@@ -126,7 +130,8 @@ def _parse_header(self):
126130
sconf = root.find("SpikeConfiguration")
127131

128132
self._sampling_rate = float(hconf.attrib["samplingRate"])
129-
num_ephy_channels = int(hconf.attrib["numChannels"])
133+
num_ephy_channels_xml = int(hconf.attrib["numChannels"])
134+
num_ephy_channels = num_ephy_channels_xml
130135

131136
# check for agreement with number of channels in xml
132137
sconf_channels = np.sum([len(x) for x in sconf])
@@ -220,7 +225,11 @@ def _parse_header(self):
220225
# we can only produce these channels for a subset of spikegadgets setup. If this criteria isn't
221226
# true then we should just use the raw_channel_ids and let the end user sort everything out
222227
if num_ephy_channels % num_chan_per_chip == 0:
223-
channel_ids = self._produce_ephys_channel_ids(num_ephy_channels, num_chan_per_chip)
228+
all_hw_chans = [int(schan.attrib["hwChan"]) for trode in sconf for schan in trode]
229+
missing_hw_chans = set(range(num_ephy_channels)) - set(all_hw_chans)
230+
channel_ids = self._produce_ephys_channel_ids(
231+
num_ephy_channels_xml, num_chan_per_chip, missing_hw_chans
232+
)
224233
raw_channel_ids = False
225234
else:
226235
raw_channel_ids = True

neo/rawio/spikeglxrawio.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -376,28 +376,15 @@ def get_segment_tuple(info):
376376
for info in info_list:
377377
info["seg_index"] = segment_tuple_to_segment_index[get_segment_tuple(info)]
378378

379-
# Probe index calculation
380-
# The calculation is ordered by slot, port, dock in that order, this is the number that appears in the filename
381-
# after imec when using native names (e.g. imec0, imec1, etc.)
382-
def get_probe_tuple(info):
383-
slot = normalize(info.get("probe_slot"))
384-
port = normalize(info.get("probe_port"))
385-
dock = normalize(info.get("probe_dock"))
386-
return (slot, port, dock)
387-
388-
# TODO: handle one box case
389-
info_list_imec = [info for info in info_list if info.get("device") != "nidq"]
390-
unique_probe_tuples = {get_probe_tuple(info) for info in info_list_imec}
391-
sorted_probe_keys = sorted(unique_probe_tuples)
392-
probe_tuple_to_probe_index = {key: idx for idx, key in enumerate(sorted_probe_keys)}
393-
394379
for info in info_list:
395-
if info.get("device") == "nidq":
396-
info["device_index"] = "" # TODO: Handle multi nidq case, maybe use meta["typeNiEnabled"]
380+
# device_kind is imec, nidq
381+
if info.get("device_kind") == "imec":
382+
info["device_index"] = info["device"].split("imec")[-1]
397383
else:
398-
info["device_index"] = probe_tuple_to_probe_index[get_probe_tuple(info)]
384+
info["device_index"] = "" # TODO: Handle multi nidq case, maybe use meta["typeNiEnabled"]
399385

400-
# Define stream base on device [imec|nidq], device_index and stream_kind [ap|lf] for imec
386+
# Define stream base on device_kind [imec|nidq], device_index and stream_kind [ap|lf] for imec
387+
# Stream format is "{device_kind}{device_index}.{stream_kind}"
401388
for info in info_list:
402389
device_kind = info["device_kind"]
403390
device_index = info["device_index"]
@@ -524,6 +511,7 @@ def extract_stream_info(meta_file, meta):
524511
# NIDQ case
525512
has_sync_trace = False
526513

514+
# This is the original name that the file had. It might not match the current name if the user changed it
527515
bin_file_path = meta["fileName"]
528516
fname = Path(bin_file_path).stem
529517

neo/test/generate_datasets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def random_segment():
165165
rec_datetime=random_datetime(),
166166
**random_annotations(4),
167167
)
168-
168+
169169
n_sigs = random.randint(0, 5)
170170
for i in range(n_sigs):
171171
seg.analogsignals.append(random_signal())

neo/test/rawiotest/test_spikegadgetsrawio.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import unittest
2+
from pathlib import Path
23

34
from neo.rawio.spikegadgetsrawio import SpikeGadgetsRawIO
45
from neo.test.rawiotest.common_rawio_test import BaseTestRawIO
6+
from numpy.testing import assert_array_equal
57

68

79
class TestSpikeGadgetsRawIO(
@@ -16,6 +18,36 @@ class TestSpikeGadgetsRawIO(
1618
"spikegadgets/SpikeGadgets_test_data_2xNpix1.0_20240318_173658.rec",
1719
]
1820

21+
def test_parse_header_missing_channels(self):
1922

20-
if __name__ == "__main__":
21-
unittest.main()
23+
file_path = Path(self.get_local_path("spikegadgets/SL18_D19_S01_F01_BOX_SLP_20230503_112642_stubbed.rec"))
24+
reader = SpikeGadgetsRawIO(filename=file_path)
25+
reader.parse_header()
26+
27+
assert_array_equal(
28+
reader.header["signal_channels"]["id"],
29+
# fmt: off
30+
[
31+
'ECU_Ain1', 'ECU_Ain2', 'ECU_Ain3', 'ECU_Ain4', 'ECU_Ain5', 'ECU_Ain6',
32+
'ECU_Ain7', 'ECU_Ain8', 'ECU_Aout1', 'ECU_Aout2', 'ECU_Aout3', 'ECU_Aout4', '0',
33+
'32', '96', '160', '192', '224', '1', '33', '65', '97', '161', '193', '225', '2', '34',
34+
'98', '162', '194', '226', '3', '35', '67', '99', '163', '195', '227', '4', '36',
35+
'100', '164', '196', '228', '5', '37', '69', '101', '165', '197', '229', '6', '38',
36+
'102', '166', '198', '230', '7', '39', '71', '103', '167', '199', '231', '8', '40',
37+
'72', '104', '136', '168', '200', '232', '9', '41', '73', '105', '137', '169', '201',
38+
'233', '10', '42', '74', '106', '138', '170', '202', '234', '11', '43', '75', '107',
39+
'139', '171', '203', '235', '12', '44', '76', '108', '140', '172', '204', '236', '13',
40+
'45', '77', '109', '141', '173', '205', '237', '14', '46', '78', '110', '142', '174',
41+
'206', '238', '15', '47', '79', '111', '143', '175', '207', '239', '80', '144', '176',
42+
'208', '240', '17', '49', '81', '145', '177', '209', '241', '82', '146', '178', '210',
43+
'242', '19', '51', '83', '147', '179', '211', '243', '84', '148', '180', '212', '244',
44+
'21', '53', '85', '149', '181', '213', '245', '86', '150', '182', '214', '246', '23',
45+
'55', '87', '151', '183', '215', '247', '24', '56', '88', '152', '184', '216', '248',
46+
'25', '57', '89', '121', '153', '185', '217', '249', '26', '58', '90', '154', '186',
47+
'218', '250', '27', '59', '91', '123', '155', '187', '219', '251', '28', '60', '92',
48+
'156', '188', '220', '252', '29', '61', '93', '125', '157', '189', '221', '253', '30',
49+
'62', '94', '158', '190', '222', '254', '31', '63', '95', '127', '159', '191', '223',
50+
'255'
51+
]
52+
# fmt: on
53+
)

neo/test/rawiotest/test_spikeglxrawio.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ class TestSpikeGLXRawIO(BaseTestRawIO, unittest.TestCase):
4343
"spikeglx/multi_trigger_multi_gate/CatGT/Supercat-A",
4444
]
4545

46+
def test_loading_only_one_probe_in_multi_probe_scenario(self):
47+
from pathlib import Path
48+
local_path_multi_probe_path = Path(self.get_local_path("spikeglx/multi_trigger_multi_gate/SpikeGLX/5-19-2022-CI0"))
49+
gate_folder_path = local_path_multi_probe_path / "5-19-2022-CI0_g0"
50+
probe_folder_path = gate_folder_path / "5-19-2022-CI0_g0_imec1"
51+
52+
rawio = SpikeGLXRawIO(probe_folder_path)
53+
rawio.parse_header()
54+
55+
expected_stream_names = ["imec1.ap", "imec1.lf"]
56+
actual_stream_names = rawio.header["signal_streams"]["name"].tolist()
57+
assert actual_stream_names == expected_stream_names, f"Expected {expected_stream_names}, but got {actual_stream_names}"
58+
4659
def test_with_location(self):
4760
rawio = SpikeGLXRawIO(self.get_local_path("spikeglx/Noise4Sam_g0"), load_channel_location=True)
4861
rawio.parse_header()

0 commit comments

Comments
 (0)