Skip to content

Commit 6b75bd3

Browse files
authored
Merge pull request #4012 from h-mayorquin/sorting_extractors_spiking_times
Add `get_unit_spike_train_in_seconds` to sorting extractor
2 parents 7dffef2 + 79968cf commit 6b75bd3

5 files changed

Lines changed: 307 additions & 30 deletions

File tree

src/spikeinterface/core/baserecording.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import warnings
44
from pathlib import Path
5+
from typing import Optional
56

67
import numpy as np
78
from probeinterface import read_probeinterface, write_probeinterface
@@ -452,7 +453,7 @@ def get_end_time(self, segment_index=None) -> float:
452453
rs = self._recording_segments[segment_index]
453454
return rs.get_end_time()
454455

455-
def has_time_vector(self, segment_index=None):
456+
def has_time_vector(self, segment_index: Optional[int] = None):
456457
"""Check if the segment of the recording has a time vector.
457458
458459
Parameters

src/spikeinterface/core/basesorting.py

Lines changed: 152 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import warnings
4-
from typing import Optional, Union
4+
from typing import Optional
55

66
import numpy as np
77

@@ -131,12 +131,52 @@ def get_total_duration(self) -> float:
131131
def get_unit_spike_train(
132132
self,
133133
unit_id: str | int,
134-
segment_index: Union[int, None] = None,
135-
start_frame: Union[int, None] = None,
136-
end_frame: Union[int, None] = None,
134+
segment_index: Optional[int] = None,
135+
start_frame: Optional[int] = None,
136+
end_frame: Optional[int] = None,
137137
return_times: bool = False,
138138
use_cache: bool = True,
139-
):
139+
) -> np.ndarray:
140+
"""
141+
Get spike train for a unit.
142+
143+
Parameters
144+
----------
145+
unit_id : str or int
146+
The unit id to retrieve spike train for
147+
segment_index : int or None, default: None
148+
The segment index to retrieve spike train from.
149+
For multi-segment objects, it is required
150+
start_frame : int or None, default: None
151+
The start frame for spike train extraction
152+
end_frame : int or None, default: None
153+
The end frame for spike train extraction
154+
return_times : bool, default: False
155+
If True, returns spike times in seconds instead of frames
156+
use_cache : bool, default: True
157+
If True, uses cached spike trains when available
158+
159+
Returns
160+
-------
161+
spike_train : np.ndarray
162+
Spike frames (or times if return_times=True)
163+
"""
164+
165+
if return_times:
166+
start_time = (
167+
self.sample_index_to_time(start_frame, segment_index=segment_index) if start_frame is not None else None
168+
)
169+
end_time = (
170+
self.sample_index_to_time(end_frame, segment_index=segment_index) if end_frame is not None else None
171+
)
172+
173+
return self.get_unit_spike_train_in_seconds(
174+
unit_id=unit_id,
175+
segment_index=segment_index,
176+
start_time=start_time,
177+
end_time=end_time,
178+
)
179+
140180
segment_index = self._check_segment_index(segment_index)
141181
if use_cache:
142182
if segment_index not in self._cached_spike_trains:
@@ -161,22 +201,101 @@ def get_unit_spike_train(
161201
unit_id=unit_id, start_frame=start_frame, end_frame=end_frame
162202
).astype("int64")
163203

164-
if return_times:
165-
if self.has_recording():
166-
times = self.get_times(segment_index=segment_index)
167-
return times[spike_frames]
168-
else:
169-
segment = self._sorting_segments[segment_index]
170-
t_start = segment._t_start if segment._t_start is not None else 0
171-
spike_times = spike_frames / self.get_sampling_frequency()
172-
return t_start + spike_times
173-
else:
174-
return spike_frames
204+
return spike_frames
205+
206+
def get_unit_spike_train_in_seconds(
207+
self,
208+
unit_id: str | int,
209+
segment_index: Optional[int] = None,
210+
start_time: Optional[float] = None,
211+
end_time: Optional[float] = None,
212+
) -> np.ndarray:
213+
"""
214+
Get spike train for a unit in seconds.
215+
216+
This method uses a three-tier approach to get spike times:
217+
1. If the sorting has a recording, use the recording's time conversion
218+
2. If the segment implements get_unit_spike_train_in_seconds(), use that directly These are the native timestamps of the format
219+
3. Fall back to standard frame-to-time conversion
220+
221+
This approach avoids double conversion for extractors that already store
222+
spike times in seconds (e.g., NWB format) and ensures consistent timing
223+
when a recording is associated with the sorting.
224+
225+
Parameters
226+
----------
227+
unit_id : str or int
228+
The unit id to retrieve spike train for
229+
segment_index : int or None, default: None
230+
The segment index to retrieve spike train from.
231+
For multi-segment objects, it is required
232+
start_time : float or None, default: None
233+
The start time in seconds for spike train extraction
234+
end_time : float or None, default: None
235+
The end time in seconds for spike train extraction
236+
237+
Returns
238+
-------
239+
spike_times : np.ndarray
240+
Spike times in seconds
241+
"""
242+
segment_index = self._check_segment_index(segment_index)
243+
segment = self._sorting_segments[segment_index]
175244

176-
def register_recording(self, recording, check_spike_frames=True):
245+
# If sorting has a registered recording, get the frames and get the times from the recording
246+
# Note that this take into account the segment start time of the recording
247+
if self.has_recording():
248+
249+
# Get all the spike times and then slice them
250+
start_frame = None
251+
end_frame = None
252+
spike_frames = self.get_unit_spike_train(
253+
unit_id=unit_id,
254+
segment_index=segment_index,
255+
start_frame=start_frame,
256+
end_frame=end_frame,
257+
return_times=False,
258+
use_cache=True,
259+
)
260+
261+
spike_times = self.sample_index_to_time(spike_frames, segment_index=segment_index)
262+
263+
# Filter to return only the spikes within the specified time range
264+
if start_time is not None:
265+
spike_times = spike_times[spike_times >= start_time]
266+
if end_time is not None:
267+
spike_times = spike_times[spike_times <= end_time]
268+
269+
return spike_times
270+
271+
# Use the native spiking times if available
272+
# Some instances might implement a method themselves to access spike times directly without having to convert
273+
# (e.g. NWB extractors)
274+
if hasattr(segment, "get_unit_spike_train_in_seconds"):
275+
return segment.get_unit_spike_train_in_seconds(unit_id=unit_id, start_time=start_time, end_time=end_time)
276+
277+
# If no recording attached and all back to frame-based conversion
278+
# Get spike train in frames and convert to times using traditional method
279+
start_frame = self.time_to_sample_index(start_time, segment_index=segment_index) if start_time else None
280+
end_frame = self.time_to_sample_index(end_time, segment_index=segment_index) if end_time else None
281+
282+
spike_frames = self.get_unit_spike_train(
283+
unit_id=unit_id,
284+
segment_index=segment_index,
285+
start_frame=start_frame,
286+
end_frame=end_frame,
287+
return_times=False,
288+
use_cache=True,
289+
)
290+
291+
t_start = segment._t_start if segment._t_start is not None else 0
292+
spike_times = spike_frames / self.get_sampling_frequency()
293+
return t_start + spike_times
294+
295+
def register_recording(self, recording, check_spike_frames: bool = True):
177296
"""
178297
Register a recording to the sorting. If the sorting and recording both contain
179-
time information, the recordings time information will be used.
298+
time information, the recording's time information will be used.
180299
181300
Parameters
182301
----------
@@ -215,7 +334,7 @@ def set_sorting_info(self, recording_dict, params_dict, log_dict):
215334
def has_recording(self) -> bool:
216335
return self._recording is not None
217336

218-
def has_time_vector(self, segment_index=None) -> bool:
337+
def has_time_vector(self, segment_index: Optional[int] = None) -> bool:
219338
"""
220339
Check if the segment of the registered recording has a time vector.
221340
"""
@@ -520,6 +639,20 @@ def time_to_sample_index(self, time, segment_index=0):
520639

521640
return sample_index
522641

642+
def sample_index_to_time(
643+
self, sample_index: int | np.ndarray, segment_index: Optional[int] = None
644+
) -> float | np.ndarray:
645+
"""
646+
Transform sample index into time in seconds
647+
"""
648+
segment_index = self._check_segment_index(segment_index)
649+
if self.has_recording():
650+
return self._recording.sample_index_to_time(sample_index, segment_index=segment_index)
651+
else:
652+
segment = self._sorting_segments[segment_index]
653+
t_start = segment._t_start if segment._t_start is not None else 0
654+
return (sample_index / self.get_sampling_frequency()) + t_start
655+
523656
def precompute_spike_trains(self, from_spike_vector=None):
524657
"""
525658
Pre-computes and caches all spike trains for this sorting

src/spikeinterface/core/tests/test_time_handling.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,10 @@ def _check_spike_times_are_correct(self, sorting, times_recording, segment_index
411411
spike_indexes = sorting.get_unit_spike_train(unit_id, segment_index=segment_index)
412412
rec_times = times_recording.get_times(segment_index=segment_index)
413413

414+
times_in_recording = rec_times[spike_indexes]
414415
assert np.array_equal(
415416
spike_times,
416-
rec_times[spike_indexes],
417+
times_in_recording,
417418
)
418419

419420
def _get_sorting_with_recording_attached(self, recording_for_durations, recording_to_attach):

src/spikeinterface/extractors/nwbextractors.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,49 @@ def get_unit_spike_train(
13211321
start_frame: Optional[int] = None,
13221322
end_frame: Optional[int] = None,
13231323
) -> np.ndarray:
1324+
# Convert frame boundaries to time boundaries
1325+
start_time = None
1326+
end_time = None
1327+
1328+
if start_frame is not None:
1329+
start_time = start_frame / self._sampling_frequency + self._t_start
1330+
1331+
if end_frame is not None:
1332+
end_time = end_frame / self._sampling_frequency + self._t_start
1333+
1334+
# Get spike times in seconds
1335+
spike_times = self.get_unit_spike_train_in_seconds(unit_id=unit_id, start_time=start_time, end_time=end_time)
1336+
1337+
# Convert to frames
1338+
frames = np.round((spike_times - self._t_start) * self._sampling_frequency)
1339+
return frames.astype("int64", copy=False)
1340+
1341+
def get_unit_spike_train_in_seconds(
1342+
self,
1343+
unit_id,
1344+
start_time: Optional[float] = None,
1345+
end_time: Optional[float] = None,
1346+
) -> np.ndarray:
1347+
"""Get the spike train times for a unit in seconds.
1348+
1349+
This method returns spike times directly in seconds without conversion
1350+
to frames, avoiding double conversion for NWB files that already store
1351+
spike times as timestamps.
1352+
1353+
Parameters
1354+
----------
1355+
unit_id
1356+
The unit id to retrieve spike train for
1357+
start_time : float, default: None
1358+
The start time in seconds for spike train extraction
1359+
end_time : float, default: None
1360+
The end time in seconds for spike train extraction
1361+
1362+
Returns
1363+
-------
1364+
spike_times : np.ndarray
1365+
Spike times in seconds
1366+
"""
13241367
# Extract the spike times for the unit
13251368
unit_index = self.parent_extractor.id_to_index(unit_id)
13261369
if unit_index == 0:
@@ -1330,19 +1373,17 @@ def get_unit_spike_train(
13301373
end_index = self.spike_times_index_data[unit_index]
13311374
spike_times = self.spike_times_data[start_index:end_index]
13321375

1333-
# Transform spike times to frames and subset
1334-
frames = np.round((spike_times - self._t_start) * self._sampling_frequency)
1335-
1376+
# Filter by time range if specified
13361377
start_index = 0
1337-
if start_frame is not None:
1338-
start_index = np.searchsorted(frames, start_frame, side="left")
1378+
if start_time is not None:
1379+
start_index = np.searchsorted(spike_times, start_time, side="left")
13391380

1340-
if end_frame is not None:
1341-
end_index = np.searchsorted(frames, end_frame, side="left")
1381+
if end_time is not None:
1382+
end_index = np.searchsorted(spike_times, end_time, side="left")
13421383
else:
1343-
end_index = frames.size
1384+
end_index = spike_times.size
13441385

1345-
return frames[start_index:end_index].astype("int64", copy=False)
1386+
return spike_times[start_index:end_index].astype("float64", copy=False)
13461387

13471388

13481389
def _find_timeseries_from_backend(group, path="", result=None, backend="hdf5"):

0 commit comments

Comments
 (0)