Skip to content

Commit 0071e4e

Browse files
authored
Merge pull request #1338 from SachsLab/cboulay/1332_blackrock_too_many_files
Add support for Blackrock NSx files with per-sample PTP nanosecond timestamps
2 parents 800525a + 145bcfb commit 0071e4e

File tree

2 files changed

+163
-12
lines changed

2 files changed

+163
-12
lines changed

neo/rawio/blackrockrawio.py

+136-12
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* Samuel Garcia - third version
88
* Lyuba Zehl, Michael Denker - fourth version
99
* Samuel Garcia, Julia Srenger - fifth version
10+
* Chadwick Boulay - FileSpec 3.0 and 3.0-PTP
1011
1112
This IO supports reading only.
1213
This IO is able to read:
@@ -17,6 +18,8 @@
1718
* 2.1
1819
* 2.2
1920
* 2.3
21+
* 3.0
22+
* 3.0 with PTP timestamps (Gemini systems)
2023
2124
The neural data channels are 1 - 128.
2225
The analog inputs are 129 - 144. (129 - 137 AC coupled, 138 - 144 DC coupled)
@@ -191,12 +194,14 @@ def __init__(
191194
"2.2": self.__read_nsx_dataheader_variant_b,
192195
"2.3": self.__read_nsx_dataheader_variant_b,
193196
"3.0": self.__read_nsx_dataheader_variant_b,
197+
"3.0-ptp": self.__read_nsx_dataheader_variant_c,
194198
}
195199
self.__nsx_data_reader = {
196200
"2.1": self.__read_nsx_data_variant_a,
197201
"2.2": self.__read_nsx_data_variant_b,
198202
"2.3": self.__read_nsx_data_variant_b,
199203
"3.0": self.__read_nsx_data_variant_b,
204+
"3.0-ptp": self.__read_nsx_data_variant_c,
200205
}
201206
self.__nsx_params = {
202207
"2.1": self.__get_nsx_param_variant_a,
@@ -312,11 +317,17 @@ def _parse_header(self):
312317
# read nsx headers
313318
self.__nsx_basic_header[nsx_nb], self.__nsx_ext_header[nsx_nb] = self.__nsx_header_reader[spec](nsx_nb)
314319

315-
# Read nsx data header(s)
320+
# The only way to know if it is the PTP-variant of file spec 3.0
321+
# is to check for nanosecond timestamp resolution.
322+
if "timestamp_resolution" in self.__nsx_basic_header[nsx_nb].dtype.names \
323+
and self.__nsx_basic_header[nsx_nb]["timestamp_resolution"] == 1_000_000_000:
324+
nsx_dataheader_reader = self.__nsx_dataheader_reader["3.0-ptp"]
325+
else:
326+
nsx_dataheader_reader = self.__nsx_dataheader_reader[spec]
316327
# for nsxdef get_analogsignal_shape(self, block_index, seg_index):
317-
self.__nsx_data_header[nsx_nb] = self.__nsx_dataheader_reader[spec](nsx_nb)
328+
self.__nsx_data_header[nsx_nb] = nsx_dataheader_reader(nsx_nb)
318329

319-
# nsx_to_load can be either int, list, 'max', all' (aka None)
330+
# nsx_to_load can be either int, list, 'max', 'all' (aka None)
320331
# here make a list only
321332
if self.nsx_to_load is None or self.nsx_to_load == "all":
322333
self.nsx_to_load = list(self._avail_nsx)
@@ -359,7 +370,14 @@ def _parse_header(self):
359370
if len(self.nsx_to_load) > 0:
360371
for nsx_nb in self.nsx_to_load:
361372
spec = self.__nsx_spec[nsx_nb]
362-
self.nsx_datas[nsx_nb] = self.__nsx_data_reader[spec](nsx_nb)
373+
# The only way to know if it is the PTP-variant of file spec 3.0
374+
# is to check for nanosecond timestamp resolution.
375+
if "timestamp_resolution" in self.__nsx_basic_header[nsx_nb].dtype.names \
376+
and self.__nsx_basic_header[nsx_nb]["timestamp_resolution"] == 1_000_000_000:
377+
_data_reader_fun = self.__nsx_data_reader["3.0-ptp"]
378+
else:
379+
_data_reader_fun = self.__nsx_data_reader[spec]
380+
self.nsx_datas[nsx_nb] = _data_reader_fun(nsx_nb)
363381

364382
sr = float(main_sampling_rate / self.__nsx_basic_header[nsx_nb]["period"])
365383
self.sig_sampling_rates[nsx_nb] = sr
@@ -415,15 +433,28 @@ def _parse_header(self):
415433
for data_bl in range(self._nb_segment):
416434
t_stop = 0.0
417435
for nsx_nb in self.nsx_to_load:
436+
spec = self.__nsx_spec[nsx_nb]
437+
if "timestamp_resolution" in self.__nsx_basic_header[nsx_nb].dtype.names:
438+
ts_res = self.__nsx_basic_header[nsx_nb]["timestamp_resolution"]
439+
elif spec == "2.1":
440+
ts_res = self.__nsx_params[spec](nsx_nb)['timestamp_resolution']
441+
else:
442+
ts_res = 30_000
443+
period = self.__nsx_basic_header[nsx_nb]["period"]
444+
sec_per_samp = period / 30_000 # Maybe 30_000 should be ['sample_resolution']
418445
length = self.nsx_datas[nsx_nb][data_bl].shape[0]
419446
if self.__nsx_data_header[nsx_nb] is None:
420447
t_start = 0.0
448+
t_stop = max(t_stop, length / self.sig_sampling_rates[nsx_nb])
421449
else:
422-
t_start = (
423-
self.__nsx_data_header[nsx_nb][data_bl]["timestamp"]
424-
/ self.__nsx_basic_header[nsx_nb]["timestamp_resolution"]
425-
)
426-
t_stop = max(t_stop, t_start + length / self.sig_sampling_rates[nsx_nb])
450+
timestamps = self.__nsx_data_header[nsx_nb][data_bl]["timestamp"]
451+
if hasattr(timestamps, "size") and timestamps.size == length:
452+
# FileSpec 3.0 with PTP -- use the per-sample timestamps
453+
t_start = timestamps[0] / ts_res
454+
t_stop = max(t_stop, timestamps[-1] / ts_res + sec_per_samp)
455+
else:
456+
t_start = timestamps / ts_res
457+
t_stop = max(t_stop, t_start + length / self.sig_sampling_rates[nsx_nb])
427458
self._sigs_t_starts[nsx_nb].append(t_start)
428459

429460
if self._avail_files["nev"]:
@@ -794,6 +825,8 @@ def __read_nsx_header_variant_a(self, nsx_nb):
794825
]
795826

796827
nsx_basic_header = np.fromfile(filename, count=1, dtype=dt0)[0]
828+
# Note: it is not possible to use recfunctions to append_fields of 'timestamp_resolution',
829+
# because the size of this object is used as the header size in later read operations.
797830

798831
# "extended" header (last field of file_id: NEURALCD)
799832
# (to facilitate compatibility with higher file specs)
@@ -901,7 +934,7 @@ def __read_nsx_dataheader_variant_b(
901934
):
902935
"""
903936
Reads the nsx data header for each data block following the offset of
904-
file spec 2.2 and 2.3.
937+
file spec 2.2, 2.3, and 3.0.
905938
"""
906939
filename = ".".join([self._filenames["nsx"], f"ns{nsx_nb}"])
907940

@@ -932,6 +965,67 @@ def __read_nsx_dataheader_variant_b(
932965

933966
return data_header
934967

968+
def __read_nsx_dataheader_variant_c(
969+
self, nsx_nb, filesize=None, offset=None, ):
970+
"""
971+
Reads the nsx data header for each data block for file spec 3.0 with PTP timestamps
972+
"""
973+
filename = ".".join([self._filenames["nsx"], f"ns{nsx_nb}"])
974+
975+
filesize = self.__get_file_size(filename)
976+
977+
data_header = {}
978+
index = 0
979+
980+
if offset is None:
981+
offset = self.__nsx_basic_header[nsx_nb]["bytes_in_headers"]
982+
983+
ptp_dt = [
984+
("reserved", "uint8"),
985+
("timestamps", "uint64"),
986+
("num_data_points", "uint32"),
987+
("samples", "int16", self.__nsx_basic_header[nsx_nb]["channel_count"])
988+
]
989+
npackets = int((filesize - offset) / np.dtype(ptp_dt).itemsize)
990+
struct_arr = np.memmap(filename, dtype=ptp_dt, shape=npackets, offset=offset, mode="r")
991+
992+
if not np.all(struct_arr["num_data_points"] == 1):
993+
# some packets have more than 1 sample. Not actually ptp. Revert to non-ptp variant.
994+
return self.__read_nsx_dataheader_variant_b(nsx_nb, filesize=filesize, offset=offset)
995+
996+
# It is still possible there was a data break and the file has multiple segments.
997+
# We can no longer rely on the presence of a header indicating a new segment,
998+
# so we look for timestamp differences greater than double the expected interval.
999+
_period = self.__nsx_basic_header[nsx_nb]["period"] # 30_000 ^-1 s per sample
1000+
_nominal_rate = 30_000 / _period # samples per sec; maybe 30_000 should be ["sample_resolution"]
1001+
_clock_rate = self.__nsx_basic_header[nsx_nb]["timestamp_resolution"] # clocks per sec
1002+
clk_per_samp = _clock_rate / _nominal_rate # clk/sec / smp/sec = clk/smp
1003+
seg_thresh_clk = int(2 * clk_per_samp)
1004+
seg_starts = np.hstack(
1005+
(0, 1 + np.argwhere(np.diff(struct_arr["timestamps"]) > seg_thresh_clk).flatten())
1006+
)
1007+
for seg_ix, seg_start_idx in enumerate(seg_starts):
1008+
if seg_ix < (len(seg_starts) - 1):
1009+
seg_stop_idx = seg_starts[seg_ix + 1]
1010+
else:
1011+
seg_stop_idx = (len(struct_arr) - 1)
1012+
seg_offset = offset + seg_start_idx * struct_arr.dtype.itemsize
1013+
num_data_pts = seg_stop_idx - seg_start_idx
1014+
seg_struct_arr = np.memmap(
1015+
filename,
1016+
dtype=ptp_dt,
1017+
shape=num_data_pts,
1018+
offset=seg_offset,
1019+
mode="r"
1020+
)
1021+
data_header[seg_ix] = {
1022+
"header": None,
1023+
"timestamp": seg_struct_arr["timestamps"], # Note, this is an array, not a scalar
1024+
"nb_data_points": num_data_pts,
1025+
"offset_to_data_block": seg_offset
1026+
}
1027+
return data_header
1028+
9351029
def __read_nsx_data_variant_a(self, nsx_nb):
9361030
"""
9371031
Extract nsx data from a 2.1 .nsx file
@@ -950,8 +1044,8 @@ def __read_nsx_data_variant_a(self, nsx_nb):
9501044

9511045
def __read_nsx_data_variant_b(self, nsx_nb):
9521046
"""
953-
Extract nsx data (blocks) from a 2.2 or 2.3 .nsx file. Blocks can arise
954-
if the recording was paused by the user.
1047+
Extract nsx data (blocks) from a 2.2, 2.3, or 3.0 .nsx file.
1048+
Blocks can arise if the recording was paused by the user.
9551049
"""
9561050
filename = ".".join([self._filenames["nsx"], f"ns{nsx_nb}"])
9571051

@@ -969,6 +1063,36 @@ def __read_nsx_data_variant_b(self, nsx_nb):
9691063

9701064
return data
9711065

1066+
def __read_nsx_data_variant_c(self, nsx_nb):
1067+
"""
1068+
Extract nsx data (blocks) from a 3.0 .nsx file with PTP timestamps
1069+
yielding a timestamp per sample. Blocks can arise
1070+
if the recording was paused by the user.
1071+
"""
1072+
filename = ".".join([self._filenames["nsx"], f"ns{nsx_nb}"])
1073+
1074+
ptp_dt = [
1075+
("reserved", "uint8"),
1076+
("timestamps", "uint64"),
1077+
("num_data_points", "uint32"),
1078+
("samples", "int16", self.__nsx_basic_header[nsx_nb]["channel_count"])
1079+
]
1080+
1081+
data = {}
1082+
for bl_id, bl_header in self.__nsx_data_header[nsx_nb].items():
1083+
struct_arr = np.memmap(
1084+
filename,
1085+
dtype=ptp_dt,
1086+
shape=bl_header["nb_data_points"],
1087+
offset=bl_header["offset_to_data_block"], mode="r"
1088+
)
1089+
# Does this concretize the data?
1090+
# If yes then investigate np.ndarray with buffer=file,
1091+
# offset=offset+13, and strides that skips 13-bytes per row.
1092+
data[bl_id] = struct_arr["samples"]
1093+
1094+
return data
1095+
9721096
def __read_nev_header(self, ext_header_variants):
9731097
"""
9741098
Extract nev header information from a of specific .nsx header variant

neo/test/rawiotest/test_blackrockrawio.py

+27
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class TestBlackrockRawIO(
2828
"blackrock/FileSpec2.3001",
2929
"blackrock/blackrock_2_1/l101210-001",
3030
"blackrock/blackrock_3_0/file_spec_3_0",
31+
"blackrock/blackrock_3_0_ptp/20231027-125608-001",
3132
]
3233

3334
@unittest.skipUnless(HAVE_SCIPY, "requires scipy")
@@ -184,6 +185,32 @@ def test_compare_blackrockio_with_matlabloader_v21(self):
184185
matlab_digievents = mts_ml[mid_ml == int(label)]
185186
assert_equal(python_digievents, matlab_digievents)
186187

188+
def test_blackrockrawio_ptp_timestamps(self):
189+
dirname = self.get_local_path("blackrock/blackrock_3_0_ptp/20231027-125608-001")
190+
reader = BlackrockRawIO(filename=dirname)
191+
reader.parse_header()
192+
193+
# 1 segment; no pauses or detectable packet drops. Was ~2.1 seconds long
194+
self.assertEqual(1, reader.block_count())
195+
self.assertEqual(1, reader.segment_count(0))
196+
t_start = reader.segment_t_start(0, 0)
197+
t_stop = reader.segment_t_stop(0, 0)
198+
self.assertAlmostEqual(2.1, t_stop - t_start, places=1)
199+
200+
# 2 streams - ns2 and ns6; each with 65 channels
201+
# 65 ns2 (1 kHz) channels, on the even channels -- every other from 2-130
202+
# 65 ns6 (RAW; 30 kHz) channels, on the odd channels -- every other from 1-129
203+
expected_rates = [1_000, 30_000]
204+
n_streams = reader.signal_streams_count()
205+
self.assertEqual(len(expected_rates), n_streams)
206+
for strm_ix in range(n_streams):
207+
reader.get_signal_sampling_rate(strm_ix)
208+
self.assertEqual(65, reader.signal_channels_count(strm_ix))
209+
self.assertAlmostEqual(expected_rates[strm_ix], reader.get_signal_sampling_rate(strm_ix), places=1)
210+
211+
# Spikes enabled on channels 1-129 but channel 129 had 0 events.
212+
self.assertEqual(128, reader.spike_channels_count())
213+
187214

188215
if __name__ == "__main__":
189216
unittest.main()

0 commit comments

Comments
 (0)