Skip to content

Commit b7a2248

Browse files
authored
Merge branch 'master' into final-2.0-fixes
2 parents dcb9440 + 7f6e973 commit b7a2248

File tree

7 files changed

+178
-44
lines changed

7 files changed

+178
-44
lines changed

neo/core/analogsignal.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@ def __new__(
200200
"""
201201
if copy is not None:
202202
raise ValueError(
203-
"`copy` is now deprecated in Neo due to removal in NumPy 2.0 and will be removed in 0.15.0."
203+
"`copy` is now deprecated in Neo due to removal in Quantites to support Numpy 2.0. "
204+
"In order to facilitate the deprecation copy can be set to None but will raise an "
205+
"error if set to True/False since this will silently do nothing. This argument will be completely "
206+
"removed in Neo 0.15.0. Please update your code base as necessary."
204207
)
205208

206209
signal = cls._rescale(signal, units=units)
@@ -210,7 +213,7 @@ def __new__(
210213
obj.shape = (-1, 1)
211214

212215
if t_start is None:
213-
raise ValueError("t_start cannot be None")
216+
raise ValueError("`t_start` cannot be None")
214217
obj._t_start = t_start
215218

216219
obj._sampling_rate = _get_sampling_rate(sampling_rate, sampling_period)

neo/core/imagesequence.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ def __new__(
124124

125125
if copy is not None:
126126
raise ValueError(
127-
"`copy` is now deprecated in Neo due to removal in NumPy 2.0 and will be removed in 0.15.0."
127+
"`copy` is now deprecated in Neo due to removal in Quantites to support Numpy 2.0. "
128+
"In order to facilitate the deprecation copy can be set to None but will raise an "
129+
"error if set to True/False since this will silently do nothing. This argument will be completely "
130+
"removed in Neo 0.15.0. Please update your code base as necessary."
128131
)
129132

130133
if spatial_scale is None:

neo/core/irregularlysampledsignal.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,10 @@ def __new__(
174174

175175
if copy is not None:
176176
raise ValueError(
177-
"`copy` is now deprecated in Neo due to removal in NumPy 2.0 and will be removed in 0.15.0."
177+
"`copy` is now deprecated in Neo due to removal in Quantites to support Numpy 2.0. "
178+
"In order to facilitate the deprecation copy can be set to None but will raise an "
179+
"error if set to True/False since this will silently do nothing. This argument will be completely "
180+
"removed in Neo 0.15.0. Please update your code base as necessary."
178181
)
179182

180183
signal = cls._rescale(signal, units=units)

neo/core/spiketrain.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,12 @@ def normalize_times_array(times, units=None, dtype=None, copy=None):
194194
"""
195195

196196
if copy is not None:
197-
raise ValueError("`copy` is now deprecated in Neo due to removal in NumPy 2.0 and will be removed in 0.15.0.")
197+
raise ValueError(
198+
"`copy` is now deprecated in Neo due to removal in Quantites to support Numpy 2.0. "
199+
"In order to facilitate the deprecation copy can be set to None but will raise an "
200+
"error if set to True/False since this will silently do nothing. This argument will be completely "
201+
"removed in Neo 0.15.0. Please update your code base as necessary."
202+
)
198203

199204
if dtype is None:
200205
if not hasattr(times, "dtype"):
@@ -352,7 +357,10 @@ def __new__(
352357
"""
353358
if copy is not None:
354359
raise ValueError(
355-
"`copy` is now deprecated in Neo due to removal in NumPy 2.0 and will be removed in 0.15.0."
360+
"`copy` is now deprecated in Neo due to removal in Quantites to support Numpy 2.0. "
361+
"In order to facilitate the deprecation copy can be set to None but will raise an "
362+
"error if set to True/False since this will silently do nothing. This argument will be completely "
363+
"removed in Neo 0.15.0. Please update your code base as necessary."
356364
)
357365

358366
if len(times) != 0 and waveforms is not None and len(times) != waveforms.shape[0]:

neo/rawio/blackrockrawio.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ def _parse_header(self):
473473
segment_mask = ev_ids == data_bl
474474
if data[segment_mask].size > 0:
475475
t = data[segment_mask][-1]["timestamp"] / self.__nev_basic_header["timestamp_resolution"]
476+
476477
max_nev_time = max(max_nev_time, t)
477478
if max_nev_time > t_stop:
478479
t_stop = max_nev_time
@@ -680,7 +681,8 @@ def _get_timestamp_slice(self, timestamp, seg_index, t_start, t_stop):
680681
if t_start is None:
681682
t_start = self._seg_t_starts[seg_index]
682683
if t_stop is None:
683-
t_stop = self._seg_t_stops[seg_index]
684+
t_stop = self._seg_t_stops[seg_index] + 1 / float(
685+
self.__nev_basic_header['timestamp_resolution'])
684686

685687
if t_start is None:
686688
ind_start = None
@@ -713,10 +715,16 @@ def _get_spike_raw_waveforms(self, block_index, seg_index, unit_index, t_start,
713715
)
714716
unit_spikes = all_spikes[mask]
715717

716-
wf_dtype = self.__nev_params("waveform_dtypes")[channel_id]
717-
wf_size = self.__nev_params("waveform_size")[channel_id]
718+
wf_dtype = self.__nev_params('waveform_dtypes')[channel_id]
719+
wf_size = self.__nev_params('waveform_size')[channel_id]
720+
wf_byte_size = np.dtype(wf_dtype).itemsize * wf_size
721+
722+
dt1 = [
723+
('extra', 'S{}'.format(unit_spikes['waveform'].dtype.itemsize - wf_byte_size)),
724+
('ch_waveform', 'S{}'.format(wf_byte_size))]
725+
726+
waveforms = unit_spikes['waveform'].view(dt1)['ch_waveform'].flatten().view(wf_dtype)
718727

719-
waveforms = unit_spikes["waveform"].flatten().view(wf_dtype)
720728
waveforms = waveforms.reshape(int(unit_spikes.size), 1, int(wf_size))
721729

722730
timestamp = unit_spikes["timestamp"]

neo/rawio/spikeglxrawio.py

Lines changed: 82 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,11 @@ class SpikeGLXRawIO(BaseRawWithBufferApiIO):
8282
8383
Notes
8484
-----
85-
* Contrary to other implementations this IO reads the entire folder and subfolders and:
86-
deals with several segments based on the `_gt0`, `_gt1`, `_gt2`, etc postfixes
87-
deals with all signals "imec0", "imec1" for neuropixel probes and also
88-
external signal like"nidq". This is the "device"
89-
* For imec device both "ap" and "lf" are extracted so one device have several "streams"
90-
* There are several versions depending the neuropixel probe generation (`1.x`/`2.x`/`3.x`)
91-
* Here, we assume that the `meta` file has the same structure across all generations.
92-
* This IO is developed based on neuropixel generation 2.0, single shank recordings.
85+
* This IO reads the entire folder and subfolders locating the `.bin` and `.meta` files
86+
* Handles gates and triggers as segments (based on the `_gt0`, `_gt1`, `_t0` , `_t1` in filenames)
87+
* Handles all signals coming from different acquisition cards ("imec0", "imec1", etc) in a typical
88+
PXIe chassis setup and also external signal like "nidq".
89+
* For imec devices both "ap" and "lf" are extracted so even a one device setup will have several "streams"
9390
9491
Examples
9592
--------
@@ -125,7 +122,6 @@ def _parse_header(self):
125122
stream_names = sorted(list(srates.keys()), key=lambda e: srates[e])[::-1]
126123
nb_segment = np.unique([info["seg_index"] for info in self.signals_info_list]).size
127124

128-
# self._memmaps = {}
129125
self.signals_info_dict = {}
130126
# one unique block
131127
self._buffer_descriptions = {0: {}}
@@ -166,7 +162,6 @@ def _parse_header(self):
166162

167163
stream_id = stream_name
168164

169-
stream_index = stream_names.index(info["stream_name"])
170165
signal_streams.append((stream_name, stream_id, buffer_id))
171166

172167
# add channels to global list
@@ -229,14 +224,25 @@ def _parse_header(self):
229224
spike_channels = np.array(spike_channels, dtype=_spike_channel_dtype)
230225

231226
# deal with nb_segment and t_start/t_stop per segment
232-
self._t_starts = {seg_index: 0.0 for seg_index in range(nb_segment)}
227+
228+
self._t_starts = {stream_name: {} for stream_name in stream_names}
233229
self._t_stops = {seg_index: 0.0 for seg_index in range(nb_segment)}
234-
for seg_index in range(nb_segment):
235-
for stream_name in stream_names:
230+
231+
for stream_name in stream_names:
232+
for seg_index in range(nb_segment):
236233
info = self.signals_info_dict[seg_index, stream_name]
234+
235+
frame_start = float(info["meta"]["firstSample"])
236+
sampling_frequency = info["sampling_rate"]
237+
t_start = frame_start / sampling_frequency
238+
239+
self._t_starts[stream_name][seg_index] = t_start
237240
t_stop = info["sample_length"] / info["sampling_rate"]
238241
self._t_stops[seg_index] = max(self._t_stops[seg_index], t_stop)
239242

243+
244+
245+
240246
# fille into header dict
241247
self.header = {}
242248
self.header["nb_block"] = 1
@@ -250,7 +256,6 @@ def _parse_header(self):
250256
# insert some annotation at some place
251257
self._generate_minimal_annotations()
252258
self._generate_minimal_annotations()
253-
block_ann = self.raw_annotations["blocks"][0]
254259

255260
for seg_index in range(nb_segment):
256261
seg_ann = self.raw_annotations["blocks"][0]["segments"][seg_index]
@@ -282,7 +287,8 @@ def _segment_t_stop(self, block_index, seg_index):
282287
return self._t_stops[seg_index]
283288

284289
def _get_signal_t_start(self, block_index, seg_index, stream_index):
285-
return 0.0
290+
stream_name = self.header["signal_streams"][stream_index]["name"]
291+
return self._t_starts[stream_name][seg_index]
286292

287293
def _event_count(self, event_channel_idx, block_index=None, seg_index=None):
288294
timestamps, _, _ = self._get_event_timestamps(block_index, seg_index, event_channel_idx, None, None)
@@ -354,23 +360,56 @@ def scan_files(dirname):
354360
if len(info_list) == 0:
355361
raise FileNotFoundError(f"No appropriate combination of .meta and .bin files were detected in {dirname}")
356362

357-
# the segment index will depend on both 'gate_num' and 'trigger_num'
358-
# so we order by 'gate_num' then 'trigger_num'
359-
# None is before any int
360-
def make_key(info):
361-
k0 = info["gate_num"]
362-
if k0 is None:
363-
k0 = -1
364-
k1 = info["trigger_num"]
365-
if k1 is None:
366-
k1 = -1
367-
return (k0, k1)
368-
369-
order_key = list({make_key(info) for info in info_list})
370-
order_key = sorted(order_key)
363+
# This sets non-integers values before integers
364+
normalize = lambda x: x if isinstance(x, int) else -1
365+
366+
# Segment index is determined by the gate_num and trigger_num in that order
367+
def get_segment_tuple(info):
368+
# Create a key from the normalized gate_num and trigger_num
369+
gate_num = normalize(info.get("gate_num"))
370+
trigger_num = normalize(info.get("trigger_num"))
371+
return (gate_num, trigger_num)
372+
373+
unique_segment_tuples = {get_segment_tuple(info) for info in info_list}
374+
sorted_keys = sorted(unique_segment_tuples)
375+
376+
# Map each unique key to a corresponding index
377+
segment_tuple_to_segment_index = {key: idx for idx, key in enumerate(sorted_keys)}
378+
371379
for info in info_list:
372-
info["seg_index"] = order_key.index(make_key(info))
380+
info["seg_index"] = segment_tuple_to_segment_index[get_segment_tuple(info)]
381+
382+
383+
# Probe index calculation
384+
# The calculation is ordered by slot, port, dock in that order, this is the number that appears in the filename
385+
# after imec when using native names (e.g. imec0, imec1, etc.)
386+
def get_probe_tuple(info):
387+
slot = normalize(info.get("probe_slot"))
388+
port = normalize(info.get("probe_port"))
389+
dock = normalize(info.get("probe_dock"))
390+
return (slot, port, dock)
391+
392+
# TODO: handle one box case
393+
info_list_imec = [info for info in info_list if info.get("device") != "nidq"]
394+
unique_probe_tuples = {get_probe_tuple(info) for info in info_list_imec}
395+
sorted_probe_keys = sorted(unique_probe_tuples)
396+
probe_tuple_to_probe_index = {key: idx for idx, key in enumerate(sorted_probe_keys)}
373397

398+
for info in info_list:
399+
if info.get("device") == "nidq":
400+
info["device_index"] = "" # TODO: Handle multi nidq case, maybe use meta["typeNiEnabled"]
401+
else:
402+
info["device_index"] = probe_tuple_to_probe_index[get_probe_tuple(info)]
403+
404+
# Define stream base on device [imec|nidq], device_index and stream_kind [ap|lf] for imec
405+
for info in info_list:
406+
device_kind = info["device_kind"]
407+
device_index = info["device_index"]
408+
stream_kind = f".{info['stream_kind']}" if info["stream_kind"] else ""
409+
stream_name = f"{device_kind}{device_index}{stream_kind}"
410+
411+
info["stream_name"] = stream_name
412+
374413
return info_list
375414

376415

@@ -488,13 +527,15 @@ def extract_stream_info(meta_file, meta):
488527
else:
489528
# NIDQ case
490529
has_sync_trace = False
491-
fname = Path(meta_file).stem
530+
531+
bin_file_path = meta["fileName"]
532+
fname = Path(bin_file_path).stem
533+
492534
run_name, gate_num, trigger_num, device, stream_kind = parse_spikeglx_fname(fname)
493535

494536
if "imec" in fname.split(".")[-2]:
495537
device = fname.split(".")[-2]
496538
stream_kind = fname.split(".")[-1]
497-
stream_name = device + "." + stream_kind
498539
units = "uV"
499540
# please note the 1e6 in gain for this uV
500541

@@ -534,7 +575,6 @@ def extract_stream_info(meta_file, meta):
534575
else:
535576
device = fname.split(".")[-1]
536577
stream_kind = ""
537-
stream_name = device
538578
units = "V"
539579
channel_gains = np.ones(num_chan)
540580

@@ -550,6 +590,10 @@ def extract_stream_info(meta_file, meta):
550590
gain_factor = float(meta["niAiRangeMax"]) / 32768
551591
channel_gains = per_channel_gain * gain_factor
552592

593+
probe_slot = meta.get("imDatPrb_slot", None)
594+
probe_port = meta.get("imDatPrb_port", None)
595+
probe_dock = meta.get("imDatPrb_dock", None)
596+
553597
info = {}
554598
info["fname"] = fname
555599
info["meta"] = meta
@@ -563,12 +607,16 @@ def extract_stream_info(meta_file, meta):
563607
info["trigger_num"] = trigger_num
564608
info["device"] = device
565609
info["stream_kind"] = stream_kind
566-
info["stream_name"] = stream_name
610+
# All non-production probes (phase 3B onwards) have "typeThis", otherwise revert to file parsing
611+
info["device_kind"] = meta.get("typeThis", device.split(".")[0])
567612
info["units"] = units
568613
info["channel_names"] = [txt.split(";")[0] for txt in meta["snsChanMap"]]
569614
info["channel_gains"] = channel_gains
570615
info["channel_offsets"] = np.zeros(info["num_chan"])
571616
info["has_sync_trace"] = has_sync_trace
617+
info["probe_slot"] = int(probe_slot) if probe_slot else None
618+
info["probe_port"] = int(probe_port) if probe_port else None
619+
info["probe_dock"] = int(probe_dock) if probe_dock else None
572620

573621
if "nidq" in device:
574622
info["digital_channels"] = []

neo/test/rawiotest/test_spikeglxrawio.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class TestSpikeGLXRawIO(BaseTestRawIO, unittest.TestCase):
3232
"spikeglx/NP2_subset_with_sync",
3333
# NP-ultra
3434
"spikeglx/np_ultra_stub",
35+
# Filename changed by the user, multi-dock
36+
"spikeglx/multi_probe_multi_dock_multi_shank_filename_without_info",
3537
# CatGT
3638
"spikeglx/multi_trigger_multi_gate/CatGT/CatGT-A",
3739
"spikeglx/multi_trigger_multi_gate/CatGT/CatGT-B",
@@ -110,6 +112,65 @@ def test_nidq_digital_channel(self):
110112
atol = 0.001
111113
assert np.allclose(on_diff, 1, atol=atol)
112114

115+
def test_t_start_reading(self):
116+
"""Test that t_start values are correctly read for all streams and segments."""
117+
118+
# Expected t_start values for each stream and segment
119+
expected_t_starts = {
120+
'imec0.ap': {
121+
0: 15.319535472007237,
122+
1: 15.339535431281986,
123+
2: 21.284723325294053,
124+
3: 21.3047232845688
125+
},
126+
'imec1.ap': {
127+
0: 15.319554693264516,
128+
1: 15.339521518106308,
129+
2: 21.284735282142822,
130+
3: 21.304702106984614
131+
},
132+
'imec0.lf': {
133+
0: 15.3191688060872,
134+
1: 15.339168765361949,
135+
2: 21.284356659374016,
136+
3: 21.304356618648765
137+
},
138+
'imec1.lf': {
139+
0: 15.319321358082725,
140+
1: 15.339321516521915,
141+
2: 21.284568614155827,
142+
3: 21.30456877259502
143+
}
144+
}
145+
146+
# Initialize the RawIO
147+
rawio = SpikeGLXRawIO(self.get_local_path("spikeglx/multi_trigger_multi_gate/SpikeGLX/5-19-2022-CI4"))
148+
rawio.parse_header()
149+
150+
# Get list of stream names
151+
stream_names = rawio.header["signal_streams"]["name"]
152+
153+
# Test t_start for each stream and segment
154+
for stream_name, expected_values in expected_t_starts.items():
155+
# Get stream index
156+
stream_index = list(stream_names).index(stream_name)
157+
158+
# Check each segment
159+
for seg_index, expected_t_start in expected_values.items():
160+
actual_t_start = rawio.get_signal_t_start(
161+
block_index=0,
162+
seg_index=seg_index,
163+
stream_index=stream_index
164+
)
165+
166+
# Use numpy.testing for proper float comparison
167+
np.testing.assert_allclose(
168+
actual_t_start,
169+
expected_t_start,
170+
rtol=1e-9,
171+
atol=1e-9,
172+
err_msg=f"Mismatch in t_start for stream '{stream_name}', segment {seg_index}"
173+
)
113174

114175
if __name__ == "__main__":
115176
unittest.main()

0 commit comments

Comments
 (0)