88
99from spikeinterface import get_global_tmp_folder
1010from spikeinterface .core import BaseRecording , BaseRecordingSegment , BaseSorting , BaseSortingSegment
11+ from spikeinterface .core .base import minimum_spike_dtype
1112from spikeinterface .core .core_tools import define_function_from_class
1213
1314if importlib .util .find_spec ("pynwb" ) is not None :
@@ -1426,6 +1427,34 @@ def __init__(
14261427 "t_start" : self .t_start ,
14271428 }
14281429
1430+ def _compute_and_cache_spike_vector (self ) -> None :
1431+ # Performance: this deliberately overrides the generic BaseSorting builder, and the override is
1432+ # the point. The generic builder reads one spike train per unit (one streamed read per unit),
1433+ # whereas an NWB Units table stores every unit's spikes in a single flat times dataset grouped by
1434+ # unit. Reading that dataset once (see NwbSortingSegment._get_all_spike_samples_and_unit_indices)
1435+ # turns num_units streamed reads into one, which is a large difference for remote/streamed files.
1436+ # Do not drop this override in favor of the generic path without a reason: doing so silently
1437+ # reintroduces the per-unit read pattern. An NWB Units table is always single-segment, so the
1438+ # single segment is built directly.
1439+ segment = self .segments [0 ]
1440+ sample_indices , unit_indices = segment ._get_all_spike_samples_and_unit_indices ()
1441+
1442+ # NWB stores spike_times grouped by unit, not globally ordered by time: pynwb itself notes that
1443+ # "the earliest spike may be in any unit" (Units.get_earliest_spike_time), and within-unit ordering
1444+ # is only a best-practice (checked by nwbinspector), not a schema guarantee. So a global sort is
1445+ # required to build the time-ordered spike vector. It is stable so unit_index order is preserved on
1446+ # equal sample_index ties, matching the generic BaseSorting builder; on already-ordered input the
1447+ # sort is a no-op (argsort returns the identity permutation).
1448+ order = np .argsort (sample_indices , stable = True )
1449+ num_spikes = sample_indices .size
1450+
1451+ spikes = np .zeros (num_spikes , dtype = minimum_spike_dtype )
1452+ spikes ["sample_index" ] = sample_indices [order ]
1453+ spikes ["unit_index" ] = unit_indices [order ]
1454+ # segment_index stays 0 for the single segment.
1455+ self ._cached_spike_vector = spikes
1456+ self ._cached_spike_vector_segment_slices = np .array ([[0 , num_spikes ]], dtype = "int64" )
1457+
14291458 @staticmethod
14301459 def fetch_available_units_tables (
14311460 file_path : str | Path ,
@@ -1468,6 +1497,18 @@ def _frame_to_time(self, frame):
14681497 return self ._time_vector [frame ]
14691498 return frame / self ._sampling_frequency + self ._t_start
14701499
1500+ def _times_to_samples (self , spike_times ) -> np .ndarray :
1501+ # Map seconds to samples. An irregular clock (per-sample timestamps) maps exactly with a
1502+ # searchsorted into the time vector; a uniform clock uses sample = (t - t_start) * sampling_frequency.
1503+ if self ._time_vector is not None :
1504+ # searchsorted needs the timestamps in memory: materialize them once, on first use, and cache.
1505+ if not isinstance (self ._time_vector , np .ndarray ):
1506+ self ._time_vector = np .asarray (self ._time_vector )
1507+ samples = np .searchsorted (self ._time_vector , spike_times , side = "right" ) - 1
1508+ else :
1509+ samples = np .round ((spike_times - self ._t_start ) * self ._sampling_frequency )
1510+ return samples .astype ("int64" , copy = False )
1511+
14711512 def get_unit_spike_train (
14721513 self ,
14731514 unit_id ,
@@ -1481,16 +1522,29 @@ def get_unit_spike_train(
14811522 # Get spike times in seconds
14821523 spike_times = self .get_unit_spike_train_in_seconds (unit_id = unit_id , start_time = start_time , end_time = end_time )
14831524
1484- # Map seconds to samples. An irregular clock (per-sample timestamps) maps exactly with a
1485- # searchsorted into the time vector; a uniform clock uses frame = (t - t_start) * sampling_frequency.
1486- if self ._time_vector is not None :
1487- # searchsorted needs the timestamps in memory: materialize them once, on first use, and cache.
1488- if not isinstance (self ._time_vector , np .ndarray ):
1489- self ._time_vector = np .asarray (self ._time_vector )
1490- frames = np .searchsorted (self ._time_vector , spike_times , side = "right" ) - 1
1491- else :
1492- frames = np .round ((spike_times - self ._t_start ) * self ._sampling_frequency )
1493- return frames .astype ("int64" , copy = False )
1525+ return self ._times_to_samples (spike_times )
1526+
1527+ def _get_all_spike_samples_and_unit_indices (self ):
1528+ """Read all units' spike trains (as samples) in one bulk read, plus each spike's unit index.
1529+
1530+ NWB stores all spike times in a single flat dataset grouped by unit (with per-unit
1531+ boundaries in ``spike_times_index_data``), so the whole segment is read at once instead
1532+ of one streamed slice per unit.
1533+ """
1534+ spike_times = np .asarray (self .spike_times_data [:], dtype = "float64" )
1535+ # NWB stores a ragged column as one flat data array (spike_times, all units concatenated) plus an
1536+ # index array of cumulative end offsets (spike_times_index): spike_times_index[u] is where unit u's
1537+ # spikes end and unit u+1's begin, so unit u is spike_times[spike_times_index[u-1] : spike_times_index[u]].
1538+ # Prepending a 0 gives every unit an explicit start, so boundaries[u]:boundaries[u+1] is unit u's
1539+ # slice; the gap between consecutive boundaries (np.diff) is that unit's spike count, and repeating
1540+ # each unit index by its count labels every spike with its unit.
1541+ # Example: 3 units with 2, 0, 3 spikes -> spike_times_index = [2, 2, 5],
1542+ # boundaries = [0, 2, 2, 5], counts = [2, 0, 3], unit_indices = [0, 0, 2, 2, 2].
1543+ boundaries = np .concatenate (([0 ], np .asarray (self .spike_times_index_data [:], dtype = "int64" )))
1544+ counts = np .diff (boundaries )
1545+ unit_indices = np .repeat (np .arange (counts .size , dtype = "int64" ), counts )
1546+ sample_indices = self ._times_to_samples (spike_times )
1547+ return sample_indices , unit_indices
14941548
14951549 def get_unit_spike_train_in_seconds (
14961550 self ,
@@ -1501,7 +1555,7 @@ def get_unit_spike_train_in_seconds(
15011555 """Get the spike train times for a unit in seconds.
15021556
15031557 This method returns spike times directly in seconds without conversion
1504- to frames , avoiding double conversion for NWB files that already store
1558+ to samples , avoiding double conversion for NWB files that already store
15051559 spike times as timestamps.
15061560
15071561 Parameters
0 commit comments