Skip to content

Commit 1230ed4

Browse files
authored
Merge branch 'master' into release-14.4
2 parents c31843f + 6357d86 commit 1230ed4

4 files changed

Lines changed: 205 additions & 4 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,6 @@ doc/*.nwb
8080
*.plx
8181
*.smr
8282
B95.zip
83-
grouped_ephys
83+
grouped_ephys
84+
uv.lock
85+
.venv

neo/rawio/blackrockrawio.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,68 @@ def _get_analogsignal_chunk(self, block_index, seg_index, i_start, i_stop, strea
667667
sig_chunk = memmap_data[i_start:i_stop, channel_indexes]
668668
return sig_chunk
669669

670+
def _get_blackrock_timestamps(self, block_index, seg_index, i_start, i_stop, stream_index):
671+
"""
672+
Return timestamps in seconds for analog signal samples in the given range.
673+
674+
The behavior depends on the file format:
675+
676+
- **PTP format (FileSpec 3.0-ptp):** Each packet in the file contains
677+
exactly one sample with its own PTP hardware timestamp at nanosecond
678+
resolution. This method returns those actual timestamps converted to
679+
seconds. Because each NSx file (e.g. ns2 at 1kHz, ns6 at 30kHz) stores
680+
its own independent PTP packets, every sample has a real timestamp.
681+
Note that PTP timestamps exhibit natural clock jitter at the
682+
nanosecond scale, so consecutive sample intervals are not perfectly
683+
uniform.
684+
685+
- **Standard formats (FileSpec 2.2, 2.3, 3.0 non-PTP):** Each data
686+
block has a single scalar timestamp for its first sample. All
687+
subsequent samples within the block are interpolated as
688+
``t_start + sample_index / sampling_rate``, assuming uniform spacing.
689+
690+
- **FileSpec 2.1:** No timestamps are stored in the file. All samples
691+
are interpolated from ``t_start=0`` using the sampling rate.
692+
693+
Parameters
694+
----------
695+
block_index : int
696+
Block index.
697+
seg_index : int
698+
Segment index.
699+
i_start : int | None
700+
First sample index. None means 0.
701+
i_stop : int | None
702+
Stop sample index (exclusive). None means end of segment.
703+
stream_index : int
704+
Stream index.
705+
706+
Returns
707+
-------
708+
timestamps : np.ndarray (float64)
709+
Timestamps in seconds for each sample in [i_start, i_stop).
710+
"""
711+
stream_id = self.header["signal_streams"][stream_index]["id"]
712+
nsx_nb = int(stream_id)
713+
714+
# Resolve None to concrete indices
715+
size = self.nsx_datas[nsx_nb][seg_index].shape[0]
716+
i_start = i_start if i_start is not None else 0
717+
i_stop = i_stop if i_stop is not None else size
718+
719+
# Check if this segment has per-sample timestamps (PTP format)
720+
raw_timestamps = self._nsx_data_header[nsx_nb][seg_index]["timestamp"]
721+
722+
if isinstance(raw_timestamps, np.ndarray) and raw_timestamps.size == size:
723+
# PTP: real hardware timestamps
724+
ts_res = float(self._nsx_basic_header[nsx_nb]["timestamp_resolution"])
725+
return raw_timestamps[i_start:i_stop].astype("float64") / ts_res
726+
else:
727+
# Non-PTP: reconstruct from t_start + index / sampling_rate
728+
t_start = self._sigs_t_starts[nsx_nb][seg_index]
729+
sr = self._nsx_sampling_frequency[nsx_nb]
730+
return t_start + np.arange(i_start, i_stop, dtype="float64") / sr
731+
670732
def _spike_count(self, block_index, seg_index, unit_index):
671733
channel_id, unit_id = self.internal_unit_ids[unit_index]
672734

neo/rawio/tdtrawio.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,17 @@ def _parse_header(self):
283283
raise ValueError("Dtype is changing!!")
284284

285285
# data buffer test if SEV file exists otherwise TEV
286-
# path = self.dirname / segment_name
287286
if self.tdt_block_mode == "multi":
288287
# for multi block datasets the names of sev files are fixed
289-
sev_stem = f"{tankname}_{segment_name}_{stream_name}_ch{chan_id}"
290-
sev_filename = (path / sev_stem).with_suffix(".sev")
288+
block_path = self.dirname / segment_name
289+
sev_regex = f"{tankname}_{segment_name}_{stream_name}_[cC]h{chan_id}.sev"
290+
sev_filename = list(block_path.glob(sev_regex))
291+
if len(sev_filename) == 1:
292+
sev_filename = sev_filename[0]
293+
elif len(sev_filename) == 0:
294+
sev_filename = None # Indirect flag for TEV Format, see issue 1087
295+
else:
296+
raise ValueError(f"Multiple SEV files matched for channel {chan_id}: {sev_filename}")
291297
else:
292298
# for single block datasets the exact name of sev files is not known
293299
sev_regex = f"*_[cC]h{chan_id}.sev"

neo/test/rawiotest/test_blackrockrawio.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,137 @@ def test_blackrockrawio_ptp_timestamps(self):
211211
# Spikes enabled on channels 1-129 but channel 129 had 0 events.
212212
self.assertEqual(128, reader.spike_channels_count())
213213

214+
def test_get_blackrock_timestamps_reconstruction(self):
215+
"""Test _get_blackrock_timestamps for non-PTP formats (reconstructed from t_start + rate)."""
216+
reader = BlackrockRawIO(filename=self.get_local_path("blackrock/FileSpec2.3001"))
217+
reader.parse_header()
218+
219+
stream_index = 0
220+
size = reader.get_signal_size(0, 0, stream_index)
221+
sr = reader.get_signal_sampling_rate(stream_index)
222+
t_start = reader.get_signal_t_start(0, 0, stream_index)
223+
224+
# Full segment timestamps
225+
timestamps = reader._get_blackrock_timestamps(0, 0, None, None, stream_index)
226+
self.assertEqual(timestamps.shape[0], size)
227+
self.assertEqual(timestamps.dtype, np.float64)
228+
229+
# Verify reconstruction: t_start + index / sr
230+
expected = t_start + np.arange(size, dtype="float64") / sr
231+
np.testing.assert_allclose(timestamps, expected)
232+
233+
def test_get_blackrock_timestamps_ptp(self):
234+
"""Test _get_blackrock_timestamps for PTP format (real hardware timestamps)."""
235+
dirname = self.get_local_path("blackrock/blackrock_3_0_ptp/20231027-125608-001")
236+
reader = BlackrockRawIO(filename=dirname)
237+
reader.parse_header()
238+
239+
nanoseconds_per_second = 1_000_000_000.0
240+
241+
# ns2 stream (stream_index=0, 1 kHz)
242+
stream_index = 0
243+
timestamps = reader._get_blackrock_timestamps(0, 0, None, None, stream_index)
244+
self.assertEqual(timestamps.shape[0], reader.get_signal_size(0, 0, stream_index))
245+
self.assertEqual(timestamps.dtype, np.float64)
246+
247+
# First 5 PTP clock values (nanoseconds since Unix epoch) read directly from the ns2 file and hardcoded here for testing
248+
ptp_clock_ns_ns2 = np.array(
249+
[1688252801327128558, 1688252801328128518, 1688252801329128718, 1688252801330128558, 1688252801331128518],
250+
dtype="uint64",
251+
)
252+
expected_ns2 = ptp_clock_ns_ns2.astype("float64") / nanoseconds_per_second
253+
np.testing.assert_array_equal(timestamps[:5], expected_ns2)
254+
255+
# ns6 stream (stream_index=1, 30 kHz) — different sampling rate, different timestamps
256+
stream_index = 1
257+
timestamps_ns6 = reader._get_blackrock_timestamps(0, 0, None, None, stream_index)
258+
self.assertEqual(timestamps_ns6.shape[0], reader.get_signal_size(0, 0, stream_index))
259+
self.assertEqual(timestamps_ns6.dtype, np.float64)
260+
261+
# First 5 PTP clock values (nanoseconds since Unix epoch) read directly from the ns6 file and hardcoded here for testing
262+
ptp_clock_ns_ns6 = np.array(
263+
[1688252801327328740, 1688252801327361940, 1688252801327395260, 1688252801327428620, 1688252801327461940],
264+
dtype="uint64",
265+
)
266+
expected_ns6 = ptp_clock_ns_ns6.astype("float64") / nanoseconds_per_second
267+
np.testing.assert_array_equal(timestamps_ns6[:5], expected_ns6)
268+
269+
def test_get_blackrock_timestamps_ptp_with_gaps(self):
270+
"""Test _get_blackrock_timestamps for PTP format with gaps and multiple segments."""
271+
dirname = self.get_local_path("blackrock/blackrock_ptp_with_missing_samples/Hub1-NWBtestfile_neural_wspikes")
272+
gap_tolerance_ms = 0.5
273+
reader = BlackrockRawIO(filename=dirname, nsx_to_load=6, gap_tolerance_ms=gap_tolerance_ms)
274+
reader.parse_header()
275+
276+
n_segments = reader.segment_count(0)
277+
self.assertEqual(n_segments, 3)
278+
279+
nanoseconds_per_second = 1_000_000_000.0
280+
281+
# This file has a single stream: ns6 (30 kHz, 1 channel)
282+
stream_index = 0
283+
expected_sizes = [632, 58, 310]
284+
285+
# First 5 PTP clock values (nanoseconds since Unix epoch) per segment, read directly from file and hardcoded here for testing
286+
first_5_ptp_clock_ns_per_segment = [
287+
np.array(
288+
[
289+
1752531864717743037,
290+
1752531864717776237,
291+
1752531864717809677,
292+
1752531864717843077,
293+
1752531864717876277,
294+
],
295+
dtype="uint64",
296+
),
297+
np.array(
298+
[
299+
1752531864739742985,
300+
1752531864739776305,
301+
1752531864739809665,
302+
1752531864739842945,
303+
1752531864739876385,
304+
],
305+
dtype="uint64",
306+
),
307+
np.array(
308+
[
309+
1752531864740742986,
310+
1752531864740776306,
311+
1752531864740809626,
312+
1752531864740842946,
313+
1752531864740876346,
314+
],
315+
dtype="uint64",
316+
),
317+
]
318+
# Last PTP clock value (nanoseconds since Unix epoch) of each segment, hardcoded here for testing
319+
last_ptp_clock_ns_per_segment = np.array(
320+
[1752531864738776304, 1752531864739709625, 1752531864751042999],
321+
dtype="uint64",
322+
)
323+
324+
for seg_index in range(n_segments):
325+
timestamps = reader._get_blackrock_timestamps(0, seg_index, None, None, stream_index)
326+
self.assertEqual(timestamps.shape[0], expected_sizes[seg_index])
327+
328+
# Verify first 5 timestamps match the clock values from the file
329+
expected_first_5 = first_5_ptp_clock_ns_per_segment[seg_index].astype("float64") / nanoseconds_per_second
330+
np.testing.assert_array_equal(timestamps[:5], expected_first_5)
331+
332+
# Verify last timestamp
333+
expected_last = last_ptp_clock_ns_per_segment[seg_index].astype("float64") / nanoseconds_per_second
334+
np.testing.assert_array_equal(timestamps[-1], expected_last)
335+
336+
# Verify the gaps between segments exceed the tolerance
337+
for seg_index in range(n_segments - 1):
338+
last_ts = last_ptp_clock_ns_per_segment[seg_index].astype("float64") / nanoseconds_per_second
339+
first_ts_next = (
340+
first_5_ptp_clock_ns_per_segment[seg_index + 1][0].astype("float64") / nanoseconds_per_second
341+
)
342+
gap_ms = (first_ts_next - last_ts) * 1000
343+
self.assertGreater(gap_ms, gap_tolerance_ms)
344+
214345
def test_gap_tolerance_ms_parameter(self):
215346
"""
216347
Test gap_tolerance_ms parameter for gap handling with files that have actual gaps.

0 commit comments

Comments
 (0)