Skip to content

Commit 67f659f

Browse files
committed
Fixes #1332 - Add support for Blackrock NSx files with per-sample PTP nanosecond timestamps
blackrockrawio - fix backwards compatibility by retrieving 'timestamp_resolution' from another location for older file spec. blackrockrawio - fix calculation of clk threshold to identify segment breaks. blackrockrawio - add unit test for filespec 3.0 ptp
1 parent 53f4dcd commit 67f659f

File tree

2 files changed

+163
-12
lines changed

2 files changed

+163
-12
lines changed

neo/rawio/blackrockrawio.py

Lines changed: 136 additions & 12 deletions
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)
@@ -189,12 +192,14 @@ def __init__(
189192
"2.2": self.__read_nsx_dataheader_variant_b,
190193
"2.3": self.__read_nsx_dataheader_variant_b,
191194
"3.0": self.__read_nsx_dataheader_variant_b,
195+
"3.0-ptp": self.__read_nsx_dataheader_variant_c,
192196
}
193197
self.__nsx_data_reader = {
194198
"2.1": self.__read_nsx_data_variant_a,
195199
"2.2": self.__read_nsx_data_variant_b,
196200
"2.3": self.__read_nsx_data_variant_b,
197201
"3.0": self.__read_nsx_data_variant_b,
202+
"3.0-ptp": self.__read_nsx_data_variant_c,
198203
}
199204
self.__nsx_params = {
200205
"2.1": self.__get_nsx_param_variant_a,
@@ -310,11 +315,17 @@ def _parse_header(self):
310315
# read nsx headers
311316
self.__nsx_basic_header[nsx_nb], self.__nsx_ext_header[nsx_nb] = self.__nsx_header_reader[spec](nsx_nb)
312317

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

317-
# nsx_to_load can be either int, list, 'max', all' (aka None)
328+
# nsx_to_load can be either int, list, 'max', 'all' (aka None)
318329
# here make a list only
319330
if self.nsx_to_load is None or self.nsx_to_load == "all":
320331
self.nsx_to_load = list(self._avail_nsx)
@@ -357,7 +368,14 @@ def _parse_header(self):
357368
if len(self.nsx_to_load) > 0:
358369
for nsx_nb in self.nsx_to_load:
359370
spec = self.__nsx_spec[nsx_nb]
360-
self.nsx_datas[nsx_nb] = self.__nsx_data_reader[spec](nsx_nb)
371+
# The only way to know if it is the PTP-variant of file spec 3.0
372+
# is to check for nanosecond timestamp resolution.
373+
if "timestamp_resolution" in self.__nsx_basic_header[nsx_nb].dtype.names \
374+
and self.__nsx_basic_header[nsx_nb]["timestamp_resolution"] == 1_000_000_000:
375+
_data_reader_fun = self.__nsx_data_reader["3.0-ptp"]
376+
else:
377+
_data_reader_fun = self.__nsx_data_reader[spec]
378+
self.nsx_datas[nsx_nb] = _data_reader_fun(nsx_nb)
361379

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

428459
if self._avail_files["nev"]:
@@ -793,6 +824,8 @@ def __read_nsx_header_variant_a(self, nsx_nb):
793824
]
794825

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

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

@@ -931,6 +964,67 @@ def __read_nsx_dataheader_variant_b(
931964

932965
return data_header
933966

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

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

@@ -968,6 +1062,36 @@ def __read_nsx_data_variant_b(self, nsx_nb):
9681062

9691063
return data
9701064

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

neo/test/rawiotest/test_blackrockrawio.py

Lines changed: 27 additions & 0 deletions
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/file_spec_3_0_ptp",
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")
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)