From 5c48ca349f0201ec8ec87d68c358ec1420496648 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 16 Apr 2026 14:36:57 -0600 Subject: [PATCH 1/4] Add time getters for sorting --- src/spikeinterface/core/basesorting.py | 50 +++++++++++++++++ .../core/tests/test_time_handling.py | 53 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/spikeinterface/core/basesorting.py b/src/spikeinterface/core/basesorting.py index cb68f3d455..64da35edc9 100644 --- a/src/spikeinterface/core/basesorting.py +++ b/src/spikeinterface/core/basesorting.py @@ -322,6 +322,11 @@ def register_recording(self, recording, check_spike_frames: bool = True): "Might be necessary for further postprocessing." ) self._recording = recording + # The recording is now the source of truth for timestamps. + # Reset the sorting's own time offset so it doesn't conflict + # with the recording's t_start when accessed through get_start_time/get_end_time. + for segment in self.segments: + segment._t_start = 0 @property def sorting_info(self): @@ -347,6 +352,51 @@ def has_time_vector(self, segment_index: int | None = None) -> bool: else: return False + def get_start_time(self, segment_index: int | None = None) -> float: + """Get the start time of the sorting segment. + + If a recording is registered, returns the recording's start time. + Otherwise returns the sorting segment's own t_start (or 0.0). + + Parameters + ---------- + segment_index : int or None, default: None + The segment index (required for multi-segment) + + Returns + ------- + float + The start time in seconds + """ + segment_index = self._check_segment_index(segment_index) + if self.has_recording(): + return self._recording.get_start_time(segment_index=segment_index) + else: + segment = self.segments[segment_index] + return segment._t_start if segment._t_start is not None else 0.0 + + def get_end_time(self, segment_index: int | None = None) -> float | None: + """Get the end time of the sorting segment. + + If a recording is registered, returns the recording's end time. + Otherwise returns None (the sorting doesn't know the recording duration). + + Parameters + ---------- + segment_index : int or None, default: None + The segment index (required for multi-segment) + + Returns + ------- + float or None + The end time in seconds, or None if no recording is registered. + """ + segment_index = self._check_segment_index(segment_index) + if self.has_recording(): + return self._recording.get_end_time(segment_index=segment_index) + else: + return None + def get_times(self, segment_index=None): """ Get time vector for a registered recording segment. diff --git a/src/spikeinterface/core/tests/test_time_handling.py b/src/spikeinterface/core/tests/test_time_handling.py index e03096ce14..50e9f75f7f 100644 --- a/src/spikeinterface/core/tests/test_time_handling.py +++ b/src/spikeinterface/core/tests/test_time_handling.py @@ -445,3 +445,56 @@ def test_shift_times_with_None_as_t_start(): assert recording.segments[0].t_start is None recording.shift_times(shift=1.0) # Shift by one seconds should not generate an error assert recording.get_start_time() == 1.0 + + +class TestSortingTimeNoRecording: + """Tests for time methods on BaseSorting without a registered recording.""" + + def test_get_start_time_default(self): + sorting = generate_sorting(num_units=5, durations=[10]) + assert sorting.get_start_time(segment_index=0) == 0.0 + + def test_get_end_time_default(self): + sorting = generate_sorting(num_units=5, durations=[10]) + assert sorting.get_end_time(segment_index=0) is None + + def test_get_start_time_with_t_start(self): + sorting = generate_sorting(num_units=5, durations=[10]) + sorting.segments[0]._t_start = 100.0 + assert sorting.get_start_time(segment_index=0) == 100.0 + + +class TestSortingTimeWithRecording: + """ + Tests for time methods on BaseSorting with a registered recording. + The key invariant: the recording is the source of truth for timestamps. + """ + + def test_get_start_end_time(self): + recording = generate_recording(num_channels=4, durations=[10]) + sorting = generate_sorting(num_units=5, durations=[10]) + sorting.register_recording(recording) + + assert sorting.get_start_time(segment_index=0) == recording.get_start_time(segment_index=0) + assert sorting.get_end_time(segment_index=0) == recording.get_end_time(segment_index=0) + + def test_register_recording_resets_t_start(self): + """Registering a recording resets _t_start so the recording is the sole source of truth.""" + sorting = generate_sorting(num_units=5, durations=[10]) + sorting.segments[0]._t_start = 100.0 + + recording = generate_recording(num_channels=4, durations=[10]) + sorting.register_recording(recording) + + assert sorting.segments[0]._t_start == 0 + assert sorting.get_start_time(segment_index=0) == recording.get_start_time(segment_index=0) + + def test_with_recording_shifted_start(self): + """Recording with a non-zero t_start is reflected in the sorting.""" + recording = generate_recording(num_channels=4, durations=[10]) + recording.shift_times(shift=50.0) + + sorting = generate_sorting(num_units=5, durations=[10]) + sorting.register_recording(recording) + + assert sorting.get_start_time(segment_index=0) == 50.0 From 097db1e286b4c3755ca6b221ebea8c7118787acb Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 16 Apr 2026 15:11:36 -0600 Subject: [PATCH 2/4] add get spike times --- src/spikeinterface/core/basesorting.py | 51 ++++++++++++------- .../core/tests/test_time_handling.py | 13 +++-- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/spikeinterface/core/basesorting.py b/src/spikeinterface/core/basesorting.py index 64da35edc9..4e86dfa3b4 100644 --- a/src/spikeinterface/core/basesorting.py +++ b/src/spikeinterface/core/basesorting.py @@ -322,11 +322,11 @@ def register_recording(self, recording, check_spike_frames: bool = True): "Might be necessary for further postprocessing." ) self._recording = recording - # The recording is now the source of truth for timestamps. - # Reset the sorting's own time offset so it doesn't conflict - # with the recording's t_start when accessed through get_start_time/get_end_time. - for segment in self.segments: - segment._t_start = 0 + # Copy the recording's start times into the sorting segments so that + # get_start_time can just read _t_start without branching. + # This also prevents double-counting if the extractor had set its own _t_start at init. + for segment_index, segment in enumerate(self.segments): + segment._t_start = recording.get_start_time(segment_index=segment_index) @property def sorting_info(self): @@ -355,9 +355,6 @@ def has_time_vector(self, segment_index: int | None = None) -> bool: def get_start_time(self, segment_index: int | None = None) -> float: """Get the start time of the sorting segment. - If a recording is registered, returns the recording's start time. - Otherwise returns the sorting segment's own t_start (or 0.0). - Parameters ---------- segment_index : int or None, default: None @@ -369,17 +366,14 @@ def get_start_time(self, segment_index: int | None = None) -> float: The start time in seconds """ segment_index = self._check_segment_index(segment_index) - if self.has_recording(): - return self._recording.get_start_time(segment_index=segment_index) - else: - segment = self.segments[segment_index] - return segment._t_start if segment._t_start is not None else 0.0 + segment = self.segments[segment_index] + return segment._t_start if segment._t_start is not None else 0.0 - def get_end_time(self, segment_index: int | None = None) -> float | None: + def get_end_time(self, segment_index: int | None = None) -> float: """Get the end time of the sorting segment. If a recording is registered, returns the recording's end time. - Otherwise returns None (the sorting doesn't know the recording duration). + Otherwise returns the time of the last spike in the segment. Parameters ---------- @@ -388,14 +382,35 @@ def get_end_time(self, segment_index: int | None = None) -> float | None: Returns ------- - float or None - The end time in seconds, or None if no recording is registered. + float + The end time in seconds """ segment_index = self._check_segment_index(segment_index) if self.has_recording(): return self._recording.get_end_time(segment_index=segment_index) else: - return None + last_spike_frame = self.get_last_spike_frame(segment_index=segment_index) + return self.sample_index_to_time(last_spike_frame, segment_index=segment_index) + + def get_last_spike_frame(self, segment_index: int | None = None) -> int: + """Get the frame index of the last spike in a segment across all units. + + Parameters + ---------- + segment_index : int or None, default: None + The segment index (required for multi-segment) + + Returns + ------- + int + The frame index of the last spike, or 0 if no spikes exist. + """ + segment_index = self._check_segment_index(segment_index) + spike_vector = self.to_spike_vector(concatenated=False) + spikes_in_segment = spike_vector[segment_index] + if len(spikes_in_segment) == 0: + return 0 + return int(np.max(spikes_in_segment["sample_index"])) def get_times(self, segment_index=None): """ diff --git a/src/spikeinterface/core/tests/test_time_handling.py b/src/spikeinterface/core/tests/test_time_handling.py index 50e9f75f7f..c4cbeb7ce1 100644 --- a/src/spikeinterface/core/tests/test_time_handling.py +++ b/src/spikeinterface/core/tests/test_time_handling.py @@ -454,9 +454,11 @@ def test_get_start_time_default(self): sorting = generate_sorting(num_units=5, durations=[10]) assert sorting.get_start_time(segment_index=0) == 0.0 - def test_get_end_time_default(self): + def test_get_end_time_is_last_spike(self): sorting = generate_sorting(num_units=5, durations=[10]) - assert sorting.get_end_time(segment_index=0) is None + last_frame = sorting.get_last_spike_frame(segment_index=0) + expected_time = last_frame / sorting.get_sampling_frequency() + assert sorting.get_end_time(segment_index=0) == expected_time def test_get_start_time_with_t_start(self): sorting = generate_sorting(num_units=5, durations=[10]) @@ -478,15 +480,16 @@ def test_get_start_end_time(self): assert sorting.get_start_time(segment_index=0) == recording.get_start_time(segment_index=0) assert sorting.get_end_time(segment_index=0) == recording.get_end_time(segment_index=0) - def test_register_recording_resets_t_start(self): - """Registering a recording resets _t_start so the recording is the sole source of truth.""" + def test_register_recording_sets_t_start_from_recording(self): + """Registering a recording copies the recording's start times into the sorting segments.""" sorting = generate_sorting(num_units=5, durations=[10]) sorting.segments[0]._t_start = 100.0 recording = generate_recording(num_channels=4, durations=[10]) sorting.register_recording(recording) - assert sorting.segments[0]._t_start == 0 + # _t_start was overwritten with the recording's start time + assert sorting.segments[0]._t_start == recording.get_start_time(segment_index=0) assert sorting.get_start_time(segment_index=0) == recording.get_start_time(segment_index=0) def test_with_recording_shifted_start(self): From a19dc3ecd63cd59ac62a46fe8b51ff7a1f053812 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 21 Apr 2026 06:58:08 -0600 Subject: [PATCH 3/4] Update src/spikeinterface/core/basesorting.py Co-authored-by: Alessio Buccino --- src/spikeinterface/core/basesorting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/basesorting.py b/src/spikeinterface/core/basesorting.py index 64da35edc9..ed28ee9a35 100644 --- a/src/spikeinterface/core/basesorting.py +++ b/src/spikeinterface/core/basesorting.py @@ -326,7 +326,7 @@ def register_recording(self, recording, check_spike_frames: bool = True): # Reset the sorting's own time offset so it doesn't conflict # with the recording's t_start when accessed through get_start_time/get_end_time. for segment in self.segments: - segment._t_start = 0 + segment._t_start = None @property def sorting_info(self): From 19031d97d1028542da3207deb45ccfa78bf9f2b9 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 21 Apr 2026 10:04:21 -0600 Subject: [PATCH 4/4] changes after chris comments --- src/spikeinterface/core/basesorting.py | 20 +++++++------------ .../core/tests/test_time_handling.py | 11 ++++++---- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/spikeinterface/core/basesorting.py b/src/spikeinterface/core/basesorting.py index c8365786c4..659a041a84 100644 --- a/src/spikeinterface/core/basesorting.py +++ b/src/spikeinterface/core/basesorting.py @@ -322,11 +322,11 @@ def register_recording(self, recording, check_spike_frames: bool = True): "Might be necessary for further postprocessing." ) self._recording = recording - # The recording is now the source of truth for timestamps. - # Reset the sorting's own time offset so it doesn't conflict - # with the recording's t_start when accessed through get_start_time/get_end_time. - for segment in self.segments: - segment._t_start = None + # Copy the recording's start times into the sorting segments. This way, + # the sorting preserves the start time even if the recording is later + # detached (e.g. analyzer saved and reloaded without the recording). + for segment_index, segment in enumerate(self.segments): + segment._t_start = recording.get_start_time(segment_index=segment_index) @property def sorting_info(self): @@ -355,9 +355,6 @@ def has_time_vector(self, segment_index: int | None = None) -> bool: def get_start_time(self, segment_index: int | None = None) -> float: """Get the start time of the sorting segment. - If a recording is registered, returns the recording's start time. - Otherwise returns the sorting's own start time (0.0 if it was not set). - Parameters ---------- segment_index : int or None, default: None @@ -369,11 +366,8 @@ def get_start_time(self, segment_index: int | None = None) -> float: The start time in seconds """ segment_index = self._check_segment_index(segment_index) - if self.has_recording(): - return self._recording.get_start_time(segment_index=segment_index) - else: - segment = self.segments[segment_index] - return segment._t_start if segment._t_start is not None else 0.0 + segment = self.segments[segment_index] + return segment._t_start if segment._t_start is not None else 0.0 def get_end_time(self, segment_index: int | None = None) -> float: """Get the end time of the sorting segment. diff --git a/src/spikeinterface/core/tests/test_time_handling.py b/src/spikeinterface/core/tests/test_time_handling.py index b48e54e776..bd74ddfe02 100644 --- a/src/spikeinterface/core/tests/test_time_handling.py +++ b/src/spikeinterface/core/tests/test_time_handling.py @@ -480,16 +480,19 @@ def test_get_start_end_time(self): assert sorting.get_start_time(segment_index=0) == recording.get_start_time(segment_index=0) assert sorting.get_end_time(segment_index=0) == recording.get_end_time(segment_index=0) - def test_register_recording_resets_t_start(self): - """Registering a recording resets _t_start so the recording is the sole source of truth.""" + def test_register_recording_copies_start_times(self): + """Registering a recording copies its start times into the sorting segments.""" sorting = generate_sorting(num_units=5, durations=[10]) sorting.segments[0]._t_start = 100.0 recording = generate_recording(num_channels=4, durations=[10]) + recording.shift_times(shift=50.0) sorting.register_recording(recording) - assert sorting.segments[0]._t_start is None - assert sorting.get_start_time(segment_index=0) == recording.get_start_time(segment_index=0) + # _t_start now mirrors the recording's start time, preserving it across + # save/load cycles even when the recording is not attached. + assert sorting.segments[0]._t_start == recording.get_start_time(segment_index=0) + assert sorting.get_start_time(segment_index=0) == 50.0 def test_with_recording_shifted_start(self): """Recording with a non-zero t_start is reflected in the sorting."""