From f1a33659159293be043cf296fd2ffb3540e72776 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 28 Aug 2026 14:03:12 +0100 Subject: [PATCH 01/37] wip --- playing.py | 54 +++++++++++++++++++++++ src/spikeinterface/extractors/cbin_ibl.py | 10 ++--- src/spikeinterface/widgets/rasters.py | 33 ++++++++------ 3 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 playing.py diff --git a/playing.py b/playing.py new file mode 100644 index 0000000000..6aab527ea1 --- /dev/null +++ b/playing.py @@ -0,0 +1,54 @@ +""" +Generate a multi-segment recording whose segments start at non-zero times, +then plot a raster of the segments. +""" + +import matplotlib.pyplot as plt + +from spikeinterface.core import append_recordings, append_sortings, generate_ground_truth_recording +import spikeinterface.widgets as sw + + +def make_multi_segment_recording(num_segments=3, seed=2205): + """Build a multi-segment recording and sorting with non-zero segment start times.""" + + common_kwargs = dict( + durations=[10.0], + sampling_frequency=30000.0, + num_channels=64, + num_units=60, + ) + + recordings = [] + sortings = [] + # Offsets so segments do NOT start at time zero. + start_time_offsets = [5.0, 120.0, 260.0] + + for seg in range(num_segments): + recording, sorting = generate_ground_truth_recording(seed=seed + seg, **common_kwargs) + # Give this segment a non-zero start time. + recording.shift_times(shift=start_time_offsets[seg % len(start_time_offsets)]) + recordings.append(recording) + sortings.append(sorting) + + multi_segment_recording = append_recordings(recordings) + multi_segment_sorting = append_sortings(sortings) + return multi_segment_recording, multi_segment_sorting + + +def main(): + recording, sorting = make_multi_segment_recording() + + print(f"Number of segments: {recording.get_num_segments()}") + for seg in range(recording.get_num_segments()): + print(f" segment {seg}: start={recording.get_start_time(seg):.1f}s end={recording.get_end_time(seg):.1f}s") + + # Plot a raster of all segments. + segment_indices = list(range(sorting.get_num_segments())) + sw.plot_rasters(sorting, segment_indices=segment_indices) + + plt.show() + + +if __name__ == "__main__": + main() diff --git a/src/spikeinterface/extractors/cbin_ibl.py b/src/spikeinterface/extractors/cbin_ibl.py index 4b222d7e59..4dae0f765c 100644 --- a/src/spikeinterface/extractors/cbin_ibl.py +++ b/src/spikeinterface/extractors/cbin_ibl.py @@ -107,12 +107,12 @@ def __init__(self, folder_path=None, load_sync_channel=False, stream_name="ap", self.set_probe(probe, in_place=True) # load num_channels_per_adc depending on probe type - ptype = probe.annotations["probe_type"] +# ptype = probe.annotations["probe_type"] - if ptype in [21, 24]: # NP2.0 - num_channels_per_adc = 16 - else: # NP1.0 - num_channels_per_adc = 12 + # if ptype in [21, 24]: # NP2.0 + num_channels_per_adc = 16 + # else: # NP1.0 + # num_channels_per_adc = 12 sample_shifts = get_neuropixels_sample_shifts_from_probe(probe, num_channels_per_adc) self.set_property("inter_sample_shift", sample_shifts) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 1ddde77bf9..232b884551 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -65,6 +65,7 @@ def __init__( y_axis_data: dict, unit_ids: list | None = None, segment_indices: list | None = None, + segment_start_stop_times: np.ndarray | None = None, durations: list | None = None, plot_histograms: bool = False, bins: int | None = None, @@ -82,6 +83,11 @@ def __init__( backend: str | None = None, **backend_kwargs, ): + if durations is not None and segment_start_stop_times is not None: + raise ValueError("`durations` should not be passed with `segment_start_stop_times`. Use `segment_start_stop_times` only.") + + if durations is not None and segment_start_stop_times is None: + segment_start_stop_times = np.r_[np.cumsum(durations)] # Set default segment boundary kwargs if not provided if segment_boundary_kwargs is None: @@ -112,15 +118,14 @@ def __init__( unit_ids = list(all_units) # Calculate cumulative durations for segment boundaries - segment_boundaries = np.cumsum(durations) - cumulative_durations = np.concatenate([[0], segment_boundaries]) + # segment_boundaries = np.array(np.r_[seg[0], seg[1]] for seg in segment_start_stop_times) # np.cumsum(durations) + # cumulative_durations = np.concatenate([[0], segment_boundaries]) # Concatenate data across segments with proper time offsets concatenated_spike_trains = {unit_id: np.array([]) for unit_id in unit_ids} concatenated_y_axis = {unit_id: np.array([]) for unit_id in unit_ids} - for offset, spike_train_segment, y_axis_segment in zip( - cumulative_durations, + for spike_train_segment, y_axis_segment in zip( [spike_train_data[idx] for idx in segments_to_use], [y_axis_data[idx] for idx in segments_to_use], ): @@ -133,11 +138,11 @@ def __init__( y_values = y_axis_segment[unit_id] # Apply offset to spike times - adjusted_times = spike_times + offset + # adjusted_times = spike_times + offset # Add to concatenated data concatenated_spike_trains[unit_id] = np.concatenate( - [concatenated_spike_trains[unit_id], adjusted_times] + [concatenated_spike_trains[unit_id], spike_times] ) concatenated_y_axis[unit_id] = np.concatenate([concatenated_y_axis[unit_id], y_values]) @@ -153,12 +158,13 @@ def __init__( unit_colors=unit_colors, y_label=y_label, title=title, - durations=durations, + segment_start_stop_times=segment_start_stop_times, + # durations=durations, plot_legend=plot_legend, bins=bins, y_ticks=y_ticks, hide_unit_selector=hide_unit_selector, - segment_boundaries=segment_boundaries, + # segment_boundaries=segment_boundaries, segment_boundary_kwargs=segment_boundary_kwargs, ) @@ -226,8 +232,8 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax_hist.plot(count, bins[:-1], color=unit_colors[unit_id], alpha=0.8) # Add segment boundary lines if provided - if getattr(dp, "segment_boundaries", None) is not None: - for boundary in dp.segment_boundaries: + if dp.segment_start_stop_times is not None: + for boundary in dp.segment_start_stop_times: scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) if dp.plot_histograms: @@ -245,9 +251,10 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): if dp.y_lim is not None: scatter_ax.set_ylim(*dp.y_lim) x_lim = dp.x_lim - if x_lim is None: - x_lim = [0, np.sum(dp.durations)] - scatter_ax.set_xlim(x_lim) + + if x_lim is None and dp.segment_start_stop_times is not None: + x_lim = (dp.segment_start_stop_times[0], dp.segment_start_stop_times[-1]) + scatter_ax.set_xlim(x_lim) if dp.y_ticks: scatter_ax.set_yticks(**dp.y_ticks) From e3c603e68765b6a4599e0ef8b6f238680e792d99 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 28 Aug 2026 16:56:02 +0100 Subject: [PATCH 02/37] update. --- playing.py | 2 +- src/spikeinterface/widgets/rasters.py | 45 ++++++++++++++++++++------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/playing.py b/playing.py index 6aab527ea1..0d4ac816cc 100644 --- a/playing.py +++ b/playing.py @@ -38,8 +38,8 @@ def make_multi_segment_recording(num_segments=3, seed=2205): def main(): recording, sorting = make_multi_segment_recording() + sorting.register_recording(recording) - print(f"Number of segments: {recording.get_num_segments()}") for seg in range(recording.get_num_segments()): print(f" segment {seg}: start={recording.get_start_time(seg):.1f}s end={recording.get_end_time(seg):.1f}s") diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 232b884551..678499ab6d 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -65,7 +65,7 @@ def __init__( y_axis_data: dict, unit_ids: list | None = None, segment_indices: list | None = None, - segment_start_stop_times: np.ndarray | None = None, + segment_start_stop_times: list[tuple] | None = None, durations: list | None = None, plot_histograms: bool = False, bins: int | None = None, @@ -87,6 +87,8 @@ def __init__( raise ValueError("`durations` should not be passed with `segment_start_stop_times`. Use `segment_start_stop_times` only.") if durations is not None and segment_start_stop_times is None: + # this is not correct! + raise NotImplementedError() segment_start_stop_times = np.r_[np.cumsum(durations)] # Set default segment boundary kwargs if not provided @@ -110,6 +112,9 @@ def __init__( else: raise ValueError("segment_index must be `list` or `None`") + # have as list of tuples up to here so can drop from segments to use. + # now convert to np.array + # Get all unit IDs present in any segment if not specified if unit_ids is None: all_units = set() @@ -117,6 +122,12 @@ def __init__( all_units.update(spike_train_data[seg_idx].keys()) unit_ids = list(all_units) + segment_start_stop_times_array = [] + for seg in segments_to_use: + segment_start_stop_times_array.append( + segment_start_stop_times.index(seg) + ) + # Calculate cumulative durations for segment boundaries # segment_boundaries = np.array(np.r_[seg[0], seg[1]] for seg in segment_start_stop_times) # np.cumsum(durations) # cumulative_durations = np.concatenate([[0], segment_boundaries]) @@ -158,7 +169,7 @@ def __init__( unit_colors=unit_colors, y_label=y_label, title=title, - segment_start_stop_times=segment_start_stop_times, + segment_start_stop_times_array=segment_start_stop_times_array, # durations=durations, plot_legend=plot_legend, bins=bins, @@ -232,9 +243,13 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax_hist.plot(count, bins[:-1], color=unit_colors[unit_id], alpha=0.8) # Add segment boundary lines if provided - if dp.segment_start_stop_times is not None: - for boundary in dp.segment_start_stop_times: - scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) + if dp.segment_start_stop_times_array is not None: + # When segments do not have times, the start/stop + # times are all about the same (estimated from spike times). + # Ignore this case so we only plot boundaries for sequential segments. + if np.all(np.diff(dp.segment_start_stop_times_array) > 0): + for boundary in dp.segment_start_stop_times_array: + scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) if dp.plot_histograms: ax_hist = self.axes.flatten()[1] @@ -252,8 +267,8 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): scatter_ax.set_ylim(*dp.y_lim) x_lim = dp.x_lim - if x_lim is None and dp.segment_start_stop_times is not None: - x_lim = (dp.segment_start_stop_times[0], dp.segment_start_stop_times[-1]) + if x_lim is None and dp.segment_start_stop_times_array is not None: + x_lim = (dp.segment_start_stop_times_array[0], dp.segment_start_stop_times_array[-1]) scatter_ax.set_xlim(x_lim) if dp.y_ticks: @@ -409,23 +424,27 @@ def __init__( unit_indices_map = {unit_id: i for i, unit_id in enumerate(unit_ids)} # Estimate segment duration from max spike time in each segment - durations = get_segment_durations(sorting, segment_indices) + # durations = get_segment_durations(sorting, segment_indices) # Extract spike data for all segments and units at once spike_train_data = {seg_idx: {} for seg_idx in segment_indices} y_axis_data = {seg_idx: {} for seg_idx in segment_indices} + segment_start_stop_times = [] for seg_idx in segment_indices: for unit_id in unit_ids: # Get spikes for this segment and unit spike_times = ( - sorting.get_unit_spike_train(unit_id=unit_id, segment_index=seg_idx) / sorting.sampling_frequency + sorting.get_unit_spike_train_in_seconds(unit_id=unit_id, segment_index=seg_idx) ) - # Store data spike_train_data[seg_idx][unit_id] = spike_times y_axis_data[seg_idx][unit_id] = unit_indices_map[unit_id] * np.ones(len(spike_times)) + segment_start_stop_times.append( + (np.min(spike_times), np.max(spike_times)) + ) + # Apply time range filtering if specified if time_range is not None: assert len(time_range) == 2, "'time_range' should be a list with start and end time in seconds" @@ -449,7 +468,11 @@ def __init__( unit_colors=unit_colors, plot_histograms=None, y_ticks=y_ticks, - durations=durations, + segment_start_stop_times=segment_start_stop_times, + # durations=durations, ) BaseRasterWidget.__init__(self, **plot_data, backend=backend, **backend_kwargs) + +# segments do not have times +# no segment start_stop times passed to base raster From b2b61129fe37b14c3f5631de5c8db345e173d775 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:41:08 +0000 Subject: [PATCH 03/37] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/widgets/rasters.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index d2c53a137b..8984f7cf25 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -86,7 +86,9 @@ def __init__( **backend_kwargs, ): if durations is not None and segment_start_stop_times is not None: - raise ValueError("`durations` should not be passed with `segment_start_stop_times`. Use `segment_start_stop_times` only.") + raise ValueError( + "`durations` should not be passed with `segment_start_stop_times`. Use `segment_start_stop_times` only." + ) if durations is not None and segment_start_stop_times is None: # this is not correct! @@ -126,9 +128,7 @@ def __init__( segment_start_stop_times_array = [] for seg in segments_to_use: - segment_start_stop_times_array.append( - segment_start_stop_times.index(seg) - ) + segment_start_stop_times_array.append(segment_start_stop_times.index(seg)) # Calculate cumulative durations for segment boundaries # segment_boundaries = np.array(np.r_[seg[0], seg[1]] for seg in segment_start_stop_times) # np.cumsum(durations) @@ -154,9 +154,7 @@ def __init__( # adjusted_times = spike_times + offset # Add to concatenated data - concatenated_spike_trains[unit_id] = np.concatenate( - [concatenated_spike_trains[unit_id], spike_times] - ) + concatenated_spike_trains[unit_id] = np.concatenate([concatenated_spike_trains[unit_id], spike_times]) concatenated_y_axis[unit_id] = np.concatenate([concatenated_y_axis[unit_id], y_values]) plot_data = dict( @@ -274,7 +272,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): if dp.y_lim is not None: scatter_ax.set_ylim(*dp.y_lim) x_lim = dp.x_lim - + if x_lim is None and dp.segment_start_stop_times_array is not None: x_lim = (dp.segment_start_stop_times_array[0], dp.segment_start_stop_times_array[-1]) scatter_ax.set_xlim(x_lim) @@ -488,16 +486,12 @@ def __init__( for seg_idx in segment_indices: for unit_id in unit_ids: # Get spikes for this segment and unit - spike_times = ( - sorting.get_unit_spike_train_in_seconds(unit_id=unit_id, segment_index=seg_idx) - ) + spike_times = sorting.get_unit_spike_train_in_seconds(unit_id=unit_id, segment_index=seg_idx) # Store data spike_train_data[seg_idx][unit_id] = spike_times y_axis_data[seg_idx][unit_id] = unit_indices_map[unit_id] * np.ones(len(spike_times)) - segment_start_stop_times.append( - (np.min(spike_times), np.max(spike_times)) - ) + segment_start_stop_times.append((np.min(spike_times), np.max(spike_times))) # Apply time range filtering if specified if time_range is not None: @@ -530,5 +524,6 @@ def __init__( BaseRasterWidget.__init__(self, **plot_data, backend=backend, **backend_kwargs) + # segments do not have times # no segment start_stop times passed to base raster From 22c6e160e5fd2a4217cad2d278e7acd5eadd4154 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 16 Sep 2026 20:44:30 +0100 Subject: [PATCH 04/37] Tidy up,tests. --- src/spikeinterface/widgets/motion.py | 14 ++++++++++++-- src/spikeinterface/widgets/rasters.py | 2 +- src/spikeinterface/widgets/tests/test_widgets.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 030f11b276..bb8cbdc149 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -229,7 +229,12 @@ def __init__( segment_locations = filtered_locations[segment_mask] # Convert peak times to seconds - spike_times = segment_peaks["sample_index"] / sampling_frequency + if recording is None: + spike_times = segment_peaks["sample_index"] / sampling_frequency + else: + spike_times = recording.sample_index_to_time( + segment_peaks["sample_index"], segment_index=seg_idx + ) # Store in dict of dicts format (using 0 as the "unit" id) spike_train_data[seg_idx] = {0: spike_times} @@ -259,7 +264,10 @@ def __init__( # Calculate segment durations for x-axis limits if recording is not None: - durations = [recording.get_duration(seg_idx) for seg_idx in segment_indices] + durations = None + segment_start_stop_times = [ + (recording.get_start_time(seg_idx), recording.get_end_time(seg_idx)) for seg_idx in segment_indices + ] else: # Find boundaries between segments using searchsorted segment_boundaries = [ @@ -270,6 +278,7 @@ def __init__( durations = [ (filtered_peaks["sample_index"][end - 1] + 1) / sampling_frequency for (_, end) in segment_boundaries ] + segment_start_stop_times = None plot_data = dict( spike_train_data=spike_train_data, @@ -281,6 +290,7 @@ def __init__( title="Peak depth", y_label="Depth [um]", durations=durations, + segment_start_stop_times=segment_start_stop_times, ) BaseRasterWidget.__init__(self, **plot_data, backend=backend, **backend_kwargs) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 8984f7cf25..2cf0dd297f 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -128,7 +128,7 @@ def __init__( segment_start_stop_times_array = [] for seg in segments_to_use: - segment_start_stop_times_array.append(segment_start_stop_times.index(seg)) + segment_start_stop_times_array.extend(segment_start_stop_times[seg]) # Calculate cumulative durations for segment boundaries # segment_boundaries = np.array(np.r_[seg[0], seg[1]] for seg in segment_start_stop_times) # np.cumsum(durations) diff --git a/src/spikeinterface/widgets/tests/test_widgets.py b/src/spikeinterface/widgets/tests/test_widgets.py index c811c386e2..8ece826de9 100644 --- a/src/spikeinterface/widgets/tests/test_widgets.py +++ b/src/spikeinterface/widgets/tests/test_widgets.py @@ -632,6 +632,11 @@ def test_plot_rasters(self): if backend not in self.skip_backends: sw.plot_rasters(self.sorting) + shifted_sorting = self.sorting.clone() + shifted_sorting.shift_times(5.0) + widget = sw.plot_rasters(shifted_sorting, backend="matplotlib") + assert widget.ax.get_xlim()[0] >= 5.0 + def test_plot_unit_probe_map(self): possible_backends = list(sw.UnitProbeMapWidget.get_possible_backends()) for backend in possible_backends: @@ -691,6 +696,16 @@ def test_drift_raster_map(self): sw.plot_drift_raster_map( peaks=peaks, peak_locations=peak_locations, recording=recording, color_amplitude=True ) + if backend == "matplotlib": + shifted_recording = recording.clone() + shifted_recording.shift_times(5.0) + widget = sw.plot_drift_raster_map( + peaks=peaks, peak_locations=peak_locations, recording=shifted_recording + ) + np.testing.assert_allclose( + widget.ax.get_xlim(), + [shifted_recording.get_start_time(), shifted_recording.get_end_time()], + ) # without recording sw.plot_drift_raster_map( peaks=peaks, From e572373c888d33c0320b5671ef1d44d7ce3ac874 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 16 Sep 2026 20:54:25 +0100 Subject: [PATCH 05/37] Tidy --- src/spikeinterface/widgets/motion.py | 4 +++- src/spikeinterface/widgets/rasters.py | 25 ++++--------------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index bb8cbdc149..cad52f3590 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -278,7 +278,9 @@ def __init__( durations = [ (filtered_peaks["sample_index"][end - 1] + 1) / sampling_frequency for (_, end) in segment_boundaries ] - segment_start_stop_times = None + segment_edges = np.concatenate([[0], np.cumsum(durations)]) + segment_start_stop_times = list(zip(segment_edges[:-1], segment_edges[1:])) + durations = None plot_data = dict( spike_train_data=spike_train_data, diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 2cf0dd297f..892722d240 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -91,9 +91,9 @@ def __init__( ) if durations is not None and segment_start_stop_times is None: - # this is not correct! - raise NotImplementedError() - segment_start_stop_times = np.r_[np.cumsum(durations)] + # This path assumes t_start are zero, which is not a good assumption. This is not really use facing, maybe we remove durations here? + edges = np.r_[0, np.cumsum(durations)] + segment_start_stop_times = list(zip(edges[:-1], edges[1:])) # Set default segment boundary kwargs if not provided if segment_boundary_kwargs is None: @@ -116,9 +116,6 @@ def __init__( else: raise ValueError("segment_index must be `list` or `None`") - # have as list of tuples up to here so can drop from segments to use. - # now convert to np.array - # Get all unit IDs present in any segment if not specified if unit_ids is None: all_units = set() @@ -130,10 +127,6 @@ def __init__( for seg in segments_to_use: segment_start_stop_times_array.extend(segment_start_stop_times[seg]) - # Calculate cumulative durations for segment boundaries - # segment_boundaries = np.array(np.r_[seg[0], seg[1]] for seg in segment_start_stop_times) # np.cumsum(durations) - # cumulative_durations = np.concatenate([[0], segment_boundaries]) - # Concatenate data across segments with proper time offsets concatenated_spike_trains = {unit_id: np.array([]) for unit_id in unit_ids} concatenated_y_axis = {unit_id: np.array([]) for unit_id in unit_ids} @@ -150,8 +143,6 @@ def __init__( # Get y-axis values for this unit y_values = y_axis_segment[unit_id] - # Apply offset to spike times - # adjusted_times = spike_times + offset # Add to concatenated data concatenated_spike_trains[unit_id] = np.concatenate([concatenated_spike_trains[unit_id], spike_times]) @@ -172,12 +163,10 @@ def __init__( y_label=y_label, title=title, segment_start_stop_times_array=segment_start_stop_times_array, - # durations=durations, plot_legend=plot_legend, bins=bins, y_ticks=y_ticks, hide_unit_selector=hide_unit_selector, - # segment_boundaries=segment_boundaries, segment_boundary_kwargs=segment_boundary_kwargs, ) @@ -250,9 +239,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): # Add segment boundary lines if provided if dp.segment_start_stop_times_array is not None: - # When segments do not have times, the start/stop - # times are all about the same (estimated from spike times). - # Ignore this case so we only plot boundaries for sequential segments. + # We only plot boundaries for sequential segments. if np.all(np.diff(dp.segment_start_stop_times_array) > 0): for boundary in dp.segment_start_stop_times_array: scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) @@ -475,9 +462,6 @@ def __init__( # Create a lookup dictionary for unit indices unit_indices_map = {unit_id: i for i, unit_id in enumerate(unit_ids)} - # Estimate segment duration from max spike time in each segment - # durations = get_segment_durations(sorting, segment_indices) - # Extract spike data for all segments and units at once spike_train_data = {seg_idx: {} for seg_idx in segment_indices} y_axis_data = {seg_idx: {} for seg_idx in segment_indices} @@ -519,7 +503,6 @@ def __init__( plot_histograms=None, y_ticks=y_ticks, segment_start_stop_times=segment_start_stop_times, - # durations=durations, ) BaseRasterWidget.__init__(self, **plot_data, backend=backend, **backend_kwargs) From 0d1745a20e971788af9eb74856368203e2306ba0 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 16 Sep 2026 21:01:31 +0100 Subject: [PATCH 06/37] Fix linting. --- src/spikeinterface/widgets/motion.py | 4 +--- src/spikeinterface/widgets/rasters.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index cad52f3590..5a062a2a6b 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -232,9 +232,7 @@ def __init__( if recording is None: spike_times = segment_peaks["sample_index"] / sampling_frequency else: - spike_times = recording.sample_index_to_time( - segment_peaks["sample_index"], segment_index=seg_idx - ) + spike_times = recording.sample_index_to_time(segment_peaks["sample_index"], segment_index=seg_idx) # Store in dict of dicts format (using 0 as the "unit" id) spike_train_data[seg_idx] = {0: spike_times} diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 892722d240..cf5e4d4452 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -143,7 +143,6 @@ def __init__( # Get y-axis values for this unit y_values = y_axis_segment[unit_id] - # Add to concatenated data concatenated_spike_trains[unit_id] = np.concatenate([concatenated_spike_trains[unit_id], spike_times]) concatenated_y_axis[unit_id] = np.concatenate([concatenated_y_axis[unit_id], y_values]) From acf27b019c921c60b7b04c948d848e8b2a94b422 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 16 Sep 2026 21:04:59 +0100 Subject: [PATCH 07/37] Small tidy up. --- src/spikeinterface/widgets/motion.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 5a062a2a6b..13e85b3069 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -260,9 +260,8 @@ def __init__( else: color_kwargs = dict(color=color, c=None, alpha=alpha) - # Calculate segment durations for x-axis limits + # Calculate segment start/stop times for x-axis limits if recording is not None: - durations = None segment_start_stop_times = [ (recording.get_start_time(seg_idx), recording.get_end_time(seg_idx)) for seg_idx in segment_indices ] @@ -272,13 +271,12 @@ def __init__( np.searchsorted(filtered_peaks["segment_index"], [seg_idx, seg_idx + 1]) for seg_idx in segment_indices ] - # Calculate durations from max sample in each segment - durations = [ + # Calculate cumulative segment end times from the last sample in each segment + segment_end_times = [ (filtered_peaks["sample_index"][end - 1] + 1) / sampling_frequency for (_, end) in segment_boundaries ] - segment_edges = np.concatenate([[0], np.cumsum(durations)]) - segment_start_stop_times = list(zip(segment_edges[:-1], segment_edges[1:])) - durations = None + segment_start_times = np.concatenate([[0], segment_end_times[:-1]]) + segment_start_stop_times = list(zip(segment_start_times, segment_end_times)) plot_data = dict( spike_train_data=spike_train_data, @@ -289,7 +287,6 @@ def __init__( scatter_decimate=scatter_decimate, title="Peak depth", y_label="Depth [um]", - durations=durations, segment_start_stop_times=segment_start_stop_times, ) From 1f31ce78412c778077f3daae58dd388add1022ad Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 12:44:16 +0100 Subject: [PATCH 08/37] Fix sorting bug, roll out across all plots, remove durations. --- playing.py | 44 +++++++++++++++++-- src/spikeinterface/core/numpyextractors.py | 3 ++ src/spikeinterface/core/sorting_tools.py | 2 +- .../core/tests/test_numpy_extractors.py | 15 +++++++ src/spikeinterface/widgets/amplitudes.py | 4 +- src/spikeinterface/widgets/motion.py | 15 ++----- src/spikeinterface/widgets/rasters.py | 11 ----- .../widgets/tests/test_widgets_utils.py | 4 +- src/spikeinterface/widgets/utils.py | 28 ++++++++++-- 9 files changed, 93 insertions(+), 33 deletions(-) diff --git a/playing.py b/playing.py index 0d4ac816cc..9e2a4d3a02 100644 --- a/playing.py +++ b/playing.py @@ -3,9 +3,14 @@ then plot a raster of the segments. """ +import matplotlib + +matplotlib.use("QtAgg") import matplotlib.pyplot as plt -from spikeinterface.core import append_recordings, append_sortings, generate_ground_truth_recording +from spikeinterface.core import append_recordings, append_sortings, create_sorting_analyzer, generate_ground_truth_recording +from spikeinterface.generation import generate_drifting_recording +from spikeinterface.preprocessing import compute_motion import spikeinterface.widgets as sw @@ -36,6 +41,22 @@ def make_multi_segment_recording(num_segments=3, seed=2205): return multi_segment_recording, multi_segment_sorting +def plot_amplitudes_output(recording, sorting): + """Plot spike amplitudes for a few units across all recording segments.""" + + analyzer = create_sorting_analyzer(sorting=sorting, recording=recording, format="memory") + analyzer.compute(["random_spikes", "waveforms", "templates", "spike_amplitudes"]) + + amplitude_widget = sw.plot_amplitudes( + analyzer, + unit_ids=sorting.unit_ids[:6], + segment_indices=list(range(sorting.get_num_segments())), + plot_histograms=True, + ) + amplitude_widget.figure.canvas.manager.set_window_title("Amplitude output") + amplitude_widget.axes.flatten()[0].set_title("Spike amplitudes") + + def main(): recording, sorting = make_multi_segment_recording() sorting.register_recording(recording) @@ -45,9 +66,26 @@ def main(): # Plot a raster of all segments. segment_indices = list(range(sorting.get_num_segments())) - sw.plot_rasters(sorting, segment_indices=segment_indices) + # sw.plot_rasters(sorting, segment_indices=segment_indices) + # plot_amplitudes_output(recording, sorting) + + + motion_recording = recording.select_segments([0]) + motion, motion_info = compute_motion( + motion_recording, + preset="rigid_fast", + estimate_motion_kwargs=dict(method="decentralized", conv_engine="numpy"), + n_jobs=-1, + progress_bar=True, + output_motion_info=True, + ) + print(motion) + motion_widget = sw.plot_motion_info(motion_info, recording=motion_recording) + motion_widget.figure.canvas.manager.set_window_title("Motion output") + motion_widget.figure.canvas.draw() + motion_widget.figure.show() - plt.show() + plt.show(block=True) if __name__ == "__main__": diff --git a/src/spikeinterface/core/numpyextractors.py b/src/spikeinterface/core/numpyextractors.py index cc04d51b61..f04bf68366 100644 --- a/src/spikeinterface/core/numpyextractors.py +++ b/src/spikeinterface/core/numpyextractors.py @@ -283,6 +283,9 @@ def from_sorting(source_sorting: BaseSorting, with_metadata=False, copy_spike_ve sorting = NumpySorting(spike_vector, source_sorting.get_sampling_frequency(), source_sorting.unit_ids.copy()) if source_sorting.has_recording(): sorting._recording = source_sorting._recording + for source_segment, target_segment in zip(source_sorting._sorting_segments, sorting._sorting_segments): + target_segment._t_start = source_segment._t_start + target_segment._native_t_start = source_segment._native_t_start if with_metadata: source_sorting.copy_metadata(sorting) return sorting diff --git a/src/spikeinterface/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py index f37b24046d..2de9dc22d2 100644 --- a/src/spikeinterface/core/sorting_tools.py +++ b/src/spikeinterface/core/sorting_tools.py @@ -447,7 +447,7 @@ def random_spikes_selection( if maximum_rate is None: raise ValueError(f"maximum_rate must be defined") - t_duration = np.sum(get_segment_durations(sorting)) + t_duration = np.sum(get_segment_durations(sorting)[0]) rng_size = min(int(t_duration * maximum_rate), max_spikes_per_unit, all_unit_indices.size) selected_unit_indices = rng.choice(all_unit_indices, size=rng_size, replace=False, shuffle=False) diff --git a/src/spikeinterface/core/tests/test_numpy_extractors.py b/src/spikeinterface/core/tests/test_numpy_extractors.py index 6eb9918b66..81f8c2300a 100644 --- a/src/spikeinterface/core/tests/test_numpy_extractors.py +++ b/src/spikeinterface/core/tests/test_numpy_extractors.py @@ -90,6 +90,21 @@ def test_NumpySorting(setup_NumpyRecording): sorting = NumpySorting.from_sorting(other_sorting) # print(sorting) + recording = generate_recording(num_channels=2, durations=[1.0, 1.0]) + recording.shift_times(shift=5.0, segment_index=0) + recording.shift_times(shift=120.0, segment_index=1) + other_sorting.register_recording(recording) + other_sorting.shift_times(2.0) + sorting_with_times = NumpySorting.from_sorting(other_sorting) + + for segment_index in range(other_sorting.get_num_segments()): + assert sorting_with_times.get_start_time(segment_index) == other_sorting.get_start_time(segment_index) + for unit_id in other_sorting.unit_ids: + assert np.array_equal( + sorting_with_times.get_unit_spike_train(unit_id, segment_index=segment_index, return_times=True), + other_sorting.get_unit_spike_train(unit_id, segment_index=segment_index, return_times=True), + ) + # construct back from kwargs keep the same array sorting2 = load(sorting.to_dict()) assert np.shares_memory(sorting2._cached_spike_vector, sorting._cached_spike_vector) diff --git a/src/spikeinterface/widgets/amplitudes.py b/src/spikeinterface/widgets/amplitudes.py index 374135aeff..91b22c03a6 100644 --- a/src/spikeinterface/widgets/amplitudes.py +++ b/src/spikeinterface/widgets/amplitudes.py @@ -122,14 +122,14 @@ def __init__( bins = 100 # Calculate durations for all segments for x-axis limits - durations = get_segment_durations(sorting, segment_indices) + _, segment_start_stop_times = get_segment_durations(sorting, segment_indices) # Build the plot data with the full dict of dicts structure plot_data = dict( unit_colors=unit_colors, plot_histograms=plot_histograms, bins=bins, - durations=durations, + segment_start_stop_times=segment_start_stop_times, unit_ids=unit_ids, hide_unit_selector=hide_unit_selector, plot_legend=plot_legend, diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 13e85b3069..c0aec4a449 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -155,7 +155,7 @@ def __init__( color: str = "Gray", clim: tuple[float, float] | None = None, alpha: float = 1, - segment_index: int | list[int] | None = None, + segment_index: int | list[int] | None = None, # TODO: this is no longer used, need to re-insert backend: str | None = None, **backend_kwargs, ): @@ -267,16 +267,9 @@ def __init__( ] else: # Find boundaries between segments using searchsorted - segment_boundaries = [ - np.searchsorted(filtered_peaks["segment_index"], [seg_idx, seg_idx + 1]) for seg_idx in segment_indices - ] - - # Calculate cumulative segment end times from the last sample in each segment - segment_end_times = [ - (filtered_peaks["sample_index"][end - 1] + 1) / sampling_frequency for (_, end) in segment_boundaries - ] - segment_start_times = np.concatenate([[0], segment_end_times[:-1]]) - segment_start_stop_times = list(zip(segment_start_times, segment_end_times)) + _, segment_start_stop_times = compute_segment_durations_from_spike_vector( + filtered_peaks, segment_indices, sampling_frequency + ) plot_data = dict( spike_train_data=spike_train_data, diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index cf5e4d4452..b2e921aa80 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -68,7 +68,6 @@ def __init__( unit_ids: list | None = None, segment_indices: list | None = None, segment_start_stop_times: list[tuple] | None = None, - durations: list | None = None, plot_histograms: bool = False, bins: int | None = None, scatter_decimate: int = 1, @@ -85,16 +84,6 @@ def __init__( backend: str | None = None, **backend_kwargs, ): - if durations is not None and segment_start_stop_times is not None: - raise ValueError( - "`durations` should not be passed with `segment_start_stop_times`. Use `segment_start_stop_times` only." - ) - - if durations is not None and segment_start_stop_times is None: - # This path assumes t_start are zero, which is not a good assumption. This is not really use facing, maybe we remove durations here? - edges = np.r_[0, np.cumsum(durations)] - segment_start_stop_times = list(zip(edges[:-1], edges[1:])) - # Set default segment boundary kwargs if not provided if segment_boundary_kwargs is None: segment_boundary_kwargs = {"color": "gray", "linestyle": "--", "alpha": 0.7} diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index 3adf31c189..8e8ce0ebe5 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -66,7 +66,7 @@ def test_get_segment_durations(): segment_indices = list(range(sorting.get_num_segments())) # Calculate durations - calculated_durations = get_segment_durations(sorting, segment_indices) + calculated_durations, _ = get_segment_durations(sorting, segment_indices) # Check results assert len(calculated_durations) == len(durations) @@ -84,7 +84,7 @@ def test_get_segment_durations(): firing_rates=15.0, ) - single_duration = get_segment_durations(sorting_single, [0])[0] + single_duration, _ = get_segment_durations(sorting_single, [0])[0] # Test that the calculated duration is reasonable assert single_duration <= 7.0 diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 40da8fd0d0..256a737f17 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -421,10 +421,32 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non spikes = sorting.to_spike_vector() + segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] + + return compute_segment_durations_from_spike_vector(spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts) + + +def compute_segment_durations_from_spike_vector( + spike_vector: np.ndarray, segment_indices: list[int], sampling_frequency: float, segment_t_starts: list[float] | None +): + """ + If segment_t_starts is `None` then assume 0 + """ + if segment_t_starts is None: + segment_t_starts = [0] * len(segment_indices) + segment_boundaries = [ - np.searchsorted(spikes["segment_index"], [seg_idx, seg_idx + 1]) for seg_idx in segment_indices + np.searchsorted(spike_vector["segment_index"], [seg_idx, seg_idx + 1]) for seg_idx in segment_indices ] + # TODO: fix this horrible loop + segment_start_stop_times = [ + ( + (spike_vector["sample_index"][start]) / sampling_frequency + segment_t_starts[idx], + (spike_vector["sample_index"][end - 1] + 1) / sampling_frequency + segment_t_starts[idx] + ) for idx, (start, end) in enumerate(segment_boundaries) + ] + + durations = np.array([end-start for (end, start) in segment_start_stop_times]) - durations = [(spikes["sample_index"][end - 1] + 1) / sorting.sampling_frequency for (_, end) in segment_boundaries] + return durations, segment_start_stop_times - return durations From c9e8e778e92eaf2106e0bd3ce68e8887a2f39846 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:44:50 +0000 Subject: [PATCH 09/37] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- playing.py | 2 +- src/spikeinterface/widgets/utils.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/playing.py b/playing.py index 9e2a4d3a02..2894b60265 100644 --- a/playing.py +++ b/playing.py @@ -77,7 +77,7 @@ def main(): estimate_motion_kwargs=dict(method="decentralized", conv_engine="numpy"), n_jobs=-1, progress_bar=True, - output_motion_info=True, + output_motion_info=True, ) print(motion) motion_widget = sw.plot_motion_info(motion_info, recording=motion_recording) diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 256a737f17..def7cc434b 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -423,11 +423,16 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] - return compute_segment_durations_from_spike_vector(spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts) + return compute_segment_durations_from_spike_vector( + spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts + ) def compute_segment_durations_from_spike_vector( - spike_vector: np.ndarray, segment_indices: list[int], sampling_frequency: float, segment_t_starts: list[float] | None + spike_vector: np.ndarray, + segment_indices: list[int], + sampling_frequency: float, + segment_t_starts: list[float] | None, ): """ If segment_t_starts is `None` then assume 0 @@ -442,11 +447,11 @@ def compute_segment_durations_from_spike_vector( segment_start_stop_times = [ ( (spike_vector["sample_index"][start]) / sampling_frequency + segment_t_starts[idx], - (spike_vector["sample_index"][end - 1] + 1) / sampling_frequency + segment_t_starts[idx] - ) for idx, (start, end) in enumerate(segment_boundaries) + (spike_vector["sample_index"][end - 1] + 1) / sampling_frequency + segment_t_starts[idx], + ) + for idx, (start, end) in enumerate(segment_boundaries) ] - durations = np.array([end-start for (end, start) in segment_start_stop_times]) + durations = np.array([end - start for (end, start) in segment_start_stop_times]) return durations, segment_start_stop_times - From 3851db2a66169f4ef5e31b2c223558a256e27d69 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 14:40:36 +0100 Subject: [PATCH 10/37] Update tests. --- .../widgets/tests/test_widgets_utils.py | 18 +++++++++++++++--- src/spikeinterface/widgets/utils.py | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index 8e8ce0ebe5..44da5c210b 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -2,6 +2,7 @@ from spikeinterface import generate_sorting from spikeinterface.widgets.utils import get_some_colors, validate_segment_indices, get_segment_durations +import numpy as np def test_get_some_colors(): @@ -50,23 +51,29 @@ def test_validate_segment_indices(): with pytest.raises(ValueError): validate_segment_indices([5], sorting_multiple) - def test_get_segment_durations(): from spikeinterface import generate_sorting # Test with a normal multi-segment sorting durations = [5.0, 10.0, 15.0] + t_starts = [10, 20, 30] # Create sorting with high fr to ensure spikes near the end segments sorting = generate_sorting( durations=durations, firing_rates=15.0, + t_starts=t_starts, ) segment_indices = list(range(sorting.get_num_segments())) # Calculate durations - calculated_durations, _ = get_segment_durations(sorting, segment_indices) + calculated_durations, segment_start_stop_times = get_segment_durations(sorting, segment_indices) + + # Check results + expected_start_stop_times = np.array([(10, 15), (20, 30), (30, 45)], dtype=float) + start_stop_times = np.array(segment_start_stop_times, dtype=float) + assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Check results assert len(calculated_durations) == len(durations) @@ -82,9 +89,14 @@ def test_get_segment_durations(): sorting_single = generate_sorting( durations=[7.0], firing_rates=15.0, + t_starts=[4], ) - single_duration, _ = get_segment_durations(sorting_single, [0])[0] + single_duration, segment_start_stop_times = get_segment_durations(sorting_single, [0]) + + expected_start_stop_times = [(4, 11)] + start_stop_times = np.array(segment_start_stop_times, dtype=float) + assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Test that the calculated duration is reasonable assert single_duration <= 7.0 diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index def7cc434b..79bb9c24de 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -452,6 +452,6 @@ def compute_segment_durations_from_spike_vector( for idx, (start, end) in enumerate(segment_boundaries) ] - durations = np.array([end - start for (end, start) in segment_start_stop_times]) + durations = np.array([end - start for (start, end) in segment_start_stop_times]) return durations, segment_start_stop_times From 9ead8844e704354316dfc41a2991e11cf60d62d7 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 14:46:32 +0100 Subject: [PATCH 11/37] Add comment. --- src/spikeinterface/core/tests/test_numpy_extractors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/spikeinterface/core/tests/test_numpy_extractors.py b/src/spikeinterface/core/tests/test_numpy_extractors.py index 81f8c2300a..73029bb670 100644 --- a/src/spikeinterface/core/tests/test_numpy_extractors.py +++ b/src/spikeinterface/core/tests/test_numpy_extractors.py @@ -90,6 +90,7 @@ def test_NumpySorting(setup_NumpyRecording): sorting = NumpySorting.from_sorting(other_sorting) # print(sorting) + # Verify recording segment offsets and shifted sorting times survive conversion to NumpySorting. recording = generate_recording(num_channels=2, durations=[1.0, 1.0]) recording.shift_times(shift=5.0, segment_index=0) recording.shift_times(shift=120.0, segment_index=1) From 98143d1c267700835cc2c5b66999e3a56ebbcd95 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 14:48:56 +0100 Subject: [PATCH 12/37] Remove unused var. --- src/spikeinterface/widgets/motion.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index c0aec4a449..11f5e95cdd 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -155,7 +155,6 @@ def __init__( color: str = "Gray", clim: tuple[float, float] | None = None, alpha: float = 1, - segment_index: int | list[int] | None = None, # TODO: this is no longer used, need to re-insert backend: str | None = None, **backend_kwargs, ): From 539b7ed16f8f92aeff5bf89f0bfda9fae2406233 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 15:15:38 +0100 Subject: [PATCH 13/37] Fix raster widgets. --- src/spikeinterface/widgets/rasters.py | 22 +++++++++++++++++++++- src/spikeinterface/widgets/utils.py | 17 +++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index b2e921aa80..5fb6d354eb 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -455,7 +455,15 @@ def __init__( y_axis_data = {seg_idx: {} for seg_idx in segment_indices} segment_start_stop_times = [] + + # Get the spikes for this segment. Also, if the sorting does not have a recording, + # build the segment start / stop times from the spike times. This is not done + # with get_segment_durations to avoid the conversion to spike_vector. for seg_idx in segment_indices: + + min_spiketime = np.inf + max_spiketime = -np.inf + for unit_id in unit_ids: # Get spikes for this segment and unit spike_times = sorting.get_unit_spike_train_in_seconds(unit_id=unit_id, segment_index=seg_idx) @@ -463,7 +471,19 @@ def __init__( spike_train_data[seg_idx][unit_id] = spike_times y_axis_data[seg_idx][unit_id] = unit_indices_map[unit_id] * np.ones(len(spike_times)) - segment_start_stop_times.append((np.min(spike_times), np.max(spike_times))) + if not sorting.has_recording(): + min_spiketime = np.min(min_spiketime, spike_times.min()) + max_spiketime = np.max(max_spiketime, spike_times.max()) + + if sorting.has_recording(): + segment_start_stop_times.append( + sorting.sorting.recording.get_start_time(seg_idx), + sorting.recording.get_end_time(seg_idx), + ) + else: + segment_start_stop_times.append( + (min_spiketime, max(spike_times)), + ) # Apply time range filtering if specified if time_range is not None: diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 79bb9c24de..25995e0ebb 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -423,6 +423,8 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] + # TODO: if the sorting has a recording, shouldn't we use sorting.recording.get_start/end_times() + return compute_segment_durations_from_spike_vector( spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts ) @@ -443,14 +445,13 @@ def compute_segment_durations_from_spike_vector( segment_boundaries = [ np.searchsorted(spike_vector["segment_index"], [seg_idx, seg_idx + 1]) for seg_idx in segment_indices ] - # TODO: fix this horrible loop - segment_start_stop_times = [ - ( - (spike_vector["sample_index"][start]) / sampling_frequency + segment_t_starts[idx], - (spike_vector["sample_index"][end - 1] + 1) / sampling_frequency + segment_t_starts[idx], - ) - for idx, (start, end) in enumerate(segment_boundaries) - ] + + for segment_t_start, (start, end) in zip(segment_t_starts, segment_boundaries): + + segment_start = spike_vector["sample_index"][start] / sampling_frequency + segment_t_start + segment_end = (spike_vector["sample_index"][end - 1] + 1) / sampling_frequency + segment_t_start + + segment_start_stop_times.append((segment_start, segment_end)) durations = np.array([end - start for (start, end) in segment_start_stop_times]) From 48ca1c93c5f5668157ef4451df2d24cb63f679e4 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 15:19:44 +0100 Subject: [PATCH 14/37] Fix rasters. --- src/spikeinterface/widgets/rasters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 5fb6d354eb..251ab07c20 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -477,8 +477,8 @@ def __init__( if sorting.has_recording(): segment_start_stop_times.append( - sorting.sorting.recording.get_start_time(seg_idx), - sorting.recording.get_end_time(seg_idx), + (sorting.get_start_time(seg_idx), + sorting.get_end_time(seg_idx)), ) else: segment_start_stop_times.append( From 25de7b48a8152e554e16b27c88a52469e4a99fb6 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 15:46:09 +0100 Subject: [PATCH 15/37] Convert to dict. --- src/spikeinterface/widgets/motion.py | 8 +-- src/spikeinterface/widgets/rasters.py | 50 ++++++++++--------- .../widgets/tests/test_widgets_utils.py | 6 ++- src/spikeinterface/widgets/utils.py | 21 ++++---- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 11f5e95cdd..36f91a0a0c 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -4,6 +4,7 @@ from spikeinterface.core import BaseRecording, SortingAnalyzer from .rasters import BaseRasterWidget +from .utils import compute_segment_durations_from_spike_vector from spikeinterface.core.motion import Motion @@ -261,9 +262,10 @@ def __init__( # Calculate segment start/stop times for x-axis limits if recording is not None: - segment_start_stop_times = [ - (recording.get_start_time(seg_idx), recording.get_end_time(seg_idx)) for seg_idx in segment_indices - ] + segment_start_stop_times = { + seg_idx: (recording.get_start_time(seg_idx), recording.get_end_time(seg_idx)) + for seg_idx in segment_indices + } else: # Find boundaries between segments using searchsorted _, segment_start_stop_times = compute_segment_durations_from_spike_vector( diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 251ab07c20..82e3a66b31 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -67,7 +67,7 @@ def __init__( sort_by_depth: bool = False, unit_ids: list | None = None, segment_indices: list | None = None, - segment_start_stop_times: list[tuple] | None = None, + segment_start_stop_times: dict[int, tuple[float, float]] | None = None, plot_histograms: bool = False, bins: int | None = None, scatter_decimate: int = 1, @@ -112,9 +112,11 @@ def __init__( all_units.update(spike_train_data[seg_idx].keys()) unit_ids = list(all_units) - segment_start_stop_times_array = [] - for seg in segments_to_use: - segment_start_stop_times_array.extend(segment_start_stop_times[seg]) + # Drop segment times we don't want to use + if segment_start_stop_times is not None: + segment_start_stop_times = { + seg: segment_start_stop_times[seg] for seg in segments_to_use + } # Concatenate data across segments with proper time offsets concatenated_spike_trains = {unit_id: np.array([]) for unit_id in unit_ids} @@ -150,7 +152,7 @@ def __init__( unit_colors=unit_colors, y_label=y_label, title=title, - segment_start_stop_times_array=segment_start_stop_times_array, + segment_start_stop_times=segment_start_stop_times, plot_legend=plot_legend, bins=bins, y_ticks=y_ticks, @@ -226,10 +228,15 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax_hist.plot(count, bins[:-1], color=unit_colors[unit_id], alpha=0.8) # Add segment boundary lines if provided - if dp.segment_start_stop_times_array is not None: + if dp.segment_start_stop_times is not None: # We only plot boundaries for sequential segments. - if np.all(np.diff(dp.segment_start_stop_times_array) > 0): - for boundary in dp.segment_start_stop_times_array: + segment_boundaries = np.asarray( + list(dp.segment_start_stop_times.values()), + dtype=float + ).ravel() + + if np.all(np.diff(segment_boundaries) > 0): + for boundary in segment_boundaries: scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) if dp.plot_histograms: @@ -248,8 +255,9 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): scatter_ax.set_ylim(*dp.y_lim) x_lim = dp.x_lim - if x_lim is None and dp.segment_start_stop_times_array is not None: - x_lim = (dp.segment_start_stop_times_array[0], dp.segment_start_stop_times_array[-1]) + if x_lim is None and dp.segment_start_stop_times is not None: + selected_segment_times = list(dp.segment_start_stop_times.values()) + x_lim = (selected_segment_times[0][0], selected_segment_times[-1][1]) scatter_ax.set_xlim(x_lim) if dp.sort_by_depth and dp.depth_dict is not None: @@ -454,7 +462,7 @@ def __init__( spike_train_data = {seg_idx: {} for seg_idx in segment_indices} y_axis_data = {seg_idx: {} for seg_idx in segment_indices} - segment_start_stop_times = [] + segment_start_stop_times = {} # Get the spikes for this segment. Also, if the sorting does not have a recording, # build the segment start / stop times from the spike times. This is not done @@ -471,19 +479,17 @@ def __init__( spike_train_data[seg_idx][unit_id] = spike_times y_axis_data[seg_idx][unit_id] = unit_indices_map[unit_id] * np.ones(len(spike_times)) - if not sorting.has_recording(): - min_spiketime = np.min(min_spiketime, spike_times.min()) - max_spiketime = np.max(max_spiketime, spike_times.max()) + if not sorting.has_recording() and len(spike_times) > 0: + min_spiketime = min(min_spiketime, spike_times[0]) + max_spiketime = max(max_spiketime, spike_times[-1]) if sorting.has_recording(): - segment_start_stop_times.append( - (sorting.get_start_time(seg_idx), - sorting.get_end_time(seg_idx)), + segment_start_stop_times[seg_idx] = ( + sorting.get_start_time(seg_idx), + sorting.get_end_time(seg_idx), ) else: - segment_start_stop_times.append( - (min_spiketime, max(spike_times)), - ) + segment_start_stop_times[seg_idx] = (min_spiketime, max_spiketime) # Apply time range filtering if specified if time_range is not None: @@ -514,7 +520,3 @@ def __init__( ) BaseRasterWidget.__init__(self, **plot_data, backend=backend, **backend_kwargs) - - -# segments do not have times -# no segment start_stop times passed to base raster diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index 44da5c210b..af2d6a9721 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -72,7 +72,8 @@ def test_get_segment_durations(): # Check results expected_start_stop_times = np.array([(10, 15), (20, 30), (30, 45)], dtype=float) - start_stop_times = np.array(segment_start_stop_times, dtype=float) + assert list(segment_start_stop_times) == segment_indices + start_stop_times = np.array(list(segment_start_stop_times.values()), dtype=float) assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Check results @@ -95,7 +96,8 @@ def test_get_segment_durations(): single_duration, segment_start_stop_times = get_segment_durations(sorting_single, [0]) expected_start_stop_times = [(4, 11)] - start_stop_times = np.array(segment_start_stop_times, dtype=float) + assert list(segment_start_stop_times) == [0] + start_stop_times = np.array(list(segment_start_stop_times.values()), dtype=float) assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Test that the calculated duration is reasonable diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 25995e0ebb..3602f9f07c 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -400,7 +400,7 @@ def validate_segment_indices(segment_indices: list[int] | None, sorting: BaseSor return segment_indices -def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = None) -> list[float]: +def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = None): """ Calculate the duration of each segment in a sorting object. @@ -413,8 +413,10 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non Returns ------- - list[float] - List of segment durations in seconds + durations : np.ndarray + Segment durations in seconds, ordered like ``segment_indices``. + segment_start_stop_times : dict[int, tuple[float, float]] + Start and stop times keyed by segment index. """ if segment_indices is None: segment_indices = range(sorting.get_num_segments()) @@ -423,8 +425,6 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] - # TODO: if the sorting has a recording, shouldn't we use sorting.recording.get_start/end_times() - return compute_segment_durations_from_spike_vector( spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts ) @@ -434,7 +434,7 @@ def compute_segment_durations_from_spike_vector( spike_vector: np.ndarray, segment_indices: list[int], sampling_frequency: float, - segment_t_starts: list[float] | None, + segment_t_starts: list[float] | None = None, ): """ If segment_t_starts is `None` then assume 0 @@ -446,13 +446,16 @@ def compute_segment_durations_from_spike_vector( np.searchsorted(spike_vector["segment_index"], [seg_idx, seg_idx + 1]) for seg_idx in segment_indices ] - for segment_t_start, (start, end) in zip(segment_t_starts, segment_boundaries): + segment_start_stop_times = {} + for seg_idx, segment_t_start, (start, end) in zip(segment_indices, segment_t_starts, segment_boundaries): segment_start = spike_vector["sample_index"][start] / sampling_frequency + segment_t_start segment_end = (spike_vector["sample_index"][end - 1] + 1) / sampling_frequency + segment_t_start - segment_start_stop_times.append((segment_start, segment_end)) + segment_start_stop_times[seg_idx] = (segment_start, segment_end) - durations = np.array([end - start for (start, end) in segment_start_stop_times]) + durations = np.array( + [segment_start_stop_times[seg_idx][1] - segment_start_stop_times[seg_idx][0] for seg_idx in segment_indices] + ) return durations, segment_start_stop_times From 5ae61c4941c564adf5fa74ade3b574f75ada41f5 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 15:57:13 +0100 Subject: [PATCH 16/37] Util for conversion to boundaries. --- src/spikeinterface/widgets/rasters.py | 10 +++------- .../widgets/tests/test_widgets_utils.py | 11 ++++++++--- src/spikeinterface/widgets/utils.py | 7 +++++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 82e3a66b31..eb40c1ccf9 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -3,7 +3,7 @@ from spikeinterface.core import SortingAnalyzer, BaseSorting from .base import BaseWidget, to_attr, default_backend_kwargs -from .utils import get_some_colors, validate_segment_indices, get_segment_durations +from .utils import get_some_colors, validate_segment_indices, get_segment_durations, segment_start_stop_times_to_boundaries class BaseRasterWidget(BaseWidget): @@ -230,10 +230,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): # Add segment boundary lines if provided if dp.segment_start_stop_times is not None: # We only plot boundaries for sequential segments. - segment_boundaries = np.asarray( - list(dp.segment_start_stop_times.values()), - dtype=float - ).ravel() + segment_boundaries = segment_start_stop_times_to_boundaries(dp.segment_start_stop_times) if np.all(np.diff(segment_boundaries) > 0): for boundary in segment_boundaries: @@ -256,8 +253,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): x_lim = dp.x_lim if x_lim is None and dp.segment_start_stop_times is not None: - selected_segment_times = list(dp.segment_start_stop_times.values()) - x_lim = (selected_segment_times[0][0], selected_segment_times[-1][1]) + x_lim = (segment_boundaries.min(), segment_boundaries.max()) scatter_ax.set_xlim(x_lim) if dp.sort_by_depth and dp.depth_dict is not None: diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index af2d6a9721..03c9d89e65 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -1,7 +1,12 @@ import pytest from spikeinterface import generate_sorting -from spikeinterface.widgets.utils import get_some_colors, validate_segment_indices, get_segment_durations +from spikeinterface.widgets.utils import ( + get_some_colors, + validate_segment_indices, + get_segment_durations, + segment_start_stop_times_to_boundaries, +) import numpy as np @@ -73,7 +78,7 @@ def test_get_segment_durations(): # Check results expected_start_stop_times = np.array([(10, 15), (20, 30), (30, 45)], dtype=float) assert list(segment_start_stop_times) == segment_indices - start_stop_times = np.array(list(segment_start_stop_times.values()), dtype=float) + start_stop_times = segment_start_stop_times_to_boundaries(segment_start_stop_times).reshape(-1, 2) assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Check results @@ -97,7 +102,7 @@ def test_get_segment_durations(): expected_start_stop_times = [(4, 11)] assert list(segment_start_stop_times) == [0] - start_stop_times = np.array(list(segment_start_stop_times.values()), dtype=float) + start_stop_times = segment_start_stop_times_to_boundaries(segment_start_stop_times).reshape(-1, 2) assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Test that the calculated duration is reasonable diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 3602f9f07c..7c0764dd6d 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -430,6 +430,13 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non ) +def segment_start_stop_times_to_boundaries( + segment_start_stop_times: dict[int, tuple[float, float]], +) -> np.ndarray: + # The dictionary can follow an out-of-order segment_indices selection, so sort the boundaries chronologically. + return np.sort(np.asarray(list(segment_start_stop_times.values()), dtype=float).ravel()) + + def compute_segment_durations_from_spike_vector( spike_vector: np.ndarray, segment_indices: list[int], From da4456c20f2bef81b9cced1095341944f3104d81 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 17:28:41 +0100 Subject: [PATCH 17/37] Tidy. --- src/spikeinterface/widgets/rasters.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index eb40c1ccf9..e13744eae3 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -227,15 +227,6 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): count, bins = np.histogram(unit_y_data, bins=bins) ax_hist.plot(count, bins[:-1], color=unit_colors[unit_id], alpha=0.8) - # Add segment boundary lines if provided - if dp.segment_start_stop_times is not None: - # We only plot boundaries for sequential segments. - segment_boundaries = segment_start_stop_times_to_boundaries(dp.segment_start_stop_times) - - if np.all(np.diff(segment_boundaries) > 0): - for boundary in segment_boundaries: - scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) - if dp.plot_histograms: ax_hist = self.axes.flatten()[1] ax_hist.set_ylim(scatter_ax.get_ylim()) @@ -252,9 +243,19 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): scatter_ax.set_ylim(*dp.y_lim) x_lim = dp.x_lim - if x_lim is None and dp.segment_start_stop_times is not None: - x_lim = (segment_boundaries.min(), segment_boundaries.max()) - scatter_ax.set_xlim(x_lim) + # Add segment boundary lines if provided and handle x limits + if dp.segment_start_stop_times is not None: + + # We only plot boundaries for sequential segments. + segment_boundaries = segment_start_stop_times_to_boundaries(dp.segment_start_stop_times) + + if np.all(np.diff(segment_boundaries) > 0): + for boundary in segment_boundaries: + scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) + + if x_lim is None: + x_lim = (segment_boundaries.min(), segment_boundaries.max()) + scatter_ax.set_xlim(x_lim) if dp.sort_by_depth and dp.depth_dict is not None: scatter_ax.set_yticks(ticks=list(range(len(dp.depth_dict))), labels=list(dp.depth_dict.keys())) From c000d5bcd6036e279e14b9434d03971eea2349a9 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 17:31:51 +0100 Subject: [PATCH 18/37] Linting. --- src/spikeinterface/widgets/rasters.py | 13 ++++++++----- .../widgets/tests/test_widgets_utils.py | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index e13744eae3..89fabeda04 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -3,7 +3,12 @@ from spikeinterface.core import SortingAnalyzer, BaseSorting from .base import BaseWidget, to_attr, default_backend_kwargs -from .utils import get_some_colors, validate_segment_indices, get_segment_durations, segment_start_stop_times_to_boundaries +from .utils import ( + get_some_colors, + validate_segment_indices, + get_segment_durations, + segment_start_stop_times_to_boundaries, +) class BaseRasterWidget(BaseWidget): @@ -114,9 +119,7 @@ def __init__( # Drop segment times we don't want to use if segment_start_stop_times is not None: - segment_start_stop_times = { - seg: segment_start_stop_times[seg] for seg in segments_to_use - } + segment_start_stop_times = {seg: segment_start_stop_times[seg] for seg in segments_to_use} # Concatenate data across segments with proper time offsets concatenated_spike_trains = {unit_id: np.array([]) for unit_id in unit_ids} @@ -244,7 +247,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): x_lim = dp.x_lim # Add segment boundary lines if provided and handle x limits - if dp.segment_start_stop_times is not None: + if dp.segment_start_stop_times is not None: # We only plot boundaries for sequential segments. segment_boundaries = segment_start_stop_times_to_boundaries(dp.segment_start_stop_times) diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index 03c9d89e65..586617b164 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -56,6 +56,7 @@ def test_validate_segment_indices(): with pytest.raises(ValueError): validate_segment_indices([5], sorting_multiple) + def test_get_segment_durations(): from spikeinterface import generate_sorting @@ -98,7 +99,7 @@ def test_get_segment_durations(): t_starts=[4], ) - single_duration, segment_start_stop_times = get_segment_durations(sorting_single, [0]) + single_duration, segment_start_stop_times = get_segment_durations(sorting_single, [0]) expected_start_stop_times = [(4, 11)] assert list(segment_start_stop_times) == [0] From edf00d42efe85a019e89bcc20653eb22ee3b8905 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 17:42:09 +0100 Subject: [PATCH 19/37] Remove stuff now in PR 4796. --- src/spikeinterface/core/numpyextractors.py | 3 --- .../core/tests/test_numpy_extractors.py | 16 ---------------- 2 files changed, 19 deletions(-) diff --git a/src/spikeinterface/core/numpyextractors.py b/src/spikeinterface/core/numpyextractors.py index f04bf68366..cc04d51b61 100644 --- a/src/spikeinterface/core/numpyextractors.py +++ b/src/spikeinterface/core/numpyextractors.py @@ -283,9 +283,6 @@ def from_sorting(source_sorting: BaseSorting, with_metadata=False, copy_spike_ve sorting = NumpySorting(spike_vector, source_sorting.get_sampling_frequency(), source_sorting.unit_ids.copy()) if source_sorting.has_recording(): sorting._recording = source_sorting._recording - for source_segment, target_segment in zip(source_sorting._sorting_segments, sorting._sorting_segments): - target_segment._t_start = source_segment._t_start - target_segment._native_t_start = source_segment._native_t_start if with_metadata: source_sorting.copy_metadata(sorting) return sorting diff --git a/src/spikeinterface/core/tests/test_numpy_extractors.py b/src/spikeinterface/core/tests/test_numpy_extractors.py index 73029bb670..6eb9918b66 100644 --- a/src/spikeinterface/core/tests/test_numpy_extractors.py +++ b/src/spikeinterface/core/tests/test_numpy_extractors.py @@ -90,22 +90,6 @@ def test_NumpySorting(setup_NumpyRecording): sorting = NumpySorting.from_sorting(other_sorting) # print(sorting) - # Verify recording segment offsets and shifted sorting times survive conversion to NumpySorting. - recording = generate_recording(num_channels=2, durations=[1.0, 1.0]) - recording.shift_times(shift=5.0, segment_index=0) - recording.shift_times(shift=120.0, segment_index=1) - other_sorting.register_recording(recording) - other_sorting.shift_times(2.0) - sorting_with_times = NumpySorting.from_sorting(other_sorting) - - for segment_index in range(other_sorting.get_num_segments()): - assert sorting_with_times.get_start_time(segment_index) == other_sorting.get_start_time(segment_index) - for unit_id in other_sorting.unit_ids: - assert np.array_equal( - sorting_with_times.get_unit_spike_train(unit_id, segment_index=segment_index, return_times=True), - other_sorting.get_unit_spike_train(unit_id, segment_index=segment_index, return_times=True), - ) - # construct back from kwargs keep the same array sorting2 = load(sorting.to_dict()) assert np.shares_memory(sorting2._cached_spike_vector, sorting._cached_spike_vector) From 3e9d80555a66bcd9af5d928f5a66836fb2202836 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 18:25:52 +0100 Subject: [PATCH 20/37] Fix test. --- src/spikeinterface/widgets/amplitudes.py | 4 +++- src/spikeinterface/widgets/rasters.py | 3 +-- src/spikeinterface/widgets/utils.py | 8 -------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/spikeinterface/widgets/amplitudes.py b/src/spikeinterface/widgets/amplitudes.py index 91b22c03a6..d8aeedb3a5 100644 --- a/src/spikeinterface/widgets/amplitudes.py +++ b/src/spikeinterface/widgets/amplitudes.py @@ -180,9 +180,11 @@ def plot_figpack(self, data_plot, **backend_kwargs): for u in unit_ids ] + end_time_sec = np.max(list(dp.segment_start_stop_times.values())) + self.view = vv_views.SpikeAmplitudes( start_time_sec=0, - end_time_sec=np.sum(dp.durations), + end_time_sec=end_time_sec, plots=sa_items, # hide_unit_selector=dp.hide_unit_selector, ) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 89fabeda04..5e70777e06 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -7,7 +7,6 @@ get_some_colors, validate_segment_indices, get_segment_durations, - segment_start_stop_times_to_boundaries, ) @@ -250,7 +249,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): if dp.segment_start_stop_times is not None: # We only plot boundaries for sequential segments. - segment_boundaries = segment_start_stop_times_to_boundaries(dp.segment_start_stop_times) + segment_boundaries = np.array(list(dp.segment_start_stop_times.values())).ravel() if np.all(np.diff(segment_boundaries) > 0): for boundary in segment_boundaries: diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 7c0764dd6d..2183bdfa03 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -429,14 +429,6 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts ) - -def segment_start_stop_times_to_boundaries( - segment_start_stop_times: dict[int, tuple[float, float]], -) -> np.ndarray: - # The dictionary can follow an out-of-order segment_indices selection, so sort the boundaries chronologically. - return np.sort(np.asarray(list(segment_start_stop_times.values()), dtype=float).ravel()) - - def compute_segment_durations_from_spike_vector( spike_vector: np.ndarray, segment_indices: list[int], From 033d3a30fad38a37b08de4597cf5869801d2f45d Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 18:29:13 +0100 Subject: [PATCH 21/37] Fix tests. --- src/spikeinterface/widgets/amplitudes.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/widgets/amplitudes.py b/src/spikeinterface/widgets/amplitudes.py index d8aeedb3a5..5a129821e8 100644 --- a/src/spikeinterface/widgets/amplitudes.py +++ b/src/spikeinterface/widgets/amplitudes.py @@ -180,10 +180,13 @@ def plot_figpack(self, data_plot, **backend_kwargs): for u in unit_ids ] - end_time_sec = np.max(list(dp.segment_start_stop_times.values())) + segment_times = dp.segment_start_stop_times.values() + + start_time_sec = min(start for start, _ in segment_times) + end_time_sec = max(stop for _, stop in segment_times) self.view = vv_views.SpikeAmplitudes( - start_time_sec=0, + start_time_sec=start_time_sec, end_time_sec=end_time_sec, plots=sa_items, # hide_unit_selector=dp.hide_unit_selector, From 244d985fbc3bc384c61a8b58e87151d665d68b66 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 17 Sep 2026 18:56:01 +0100 Subject: [PATCH 22/37] Fix tests....again. --- src/spikeinterface/widgets/tests/test_widgets_utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index 586617b164..1a3868acea 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -5,7 +5,6 @@ get_some_colors, validate_segment_indices, get_segment_durations, - segment_start_stop_times_to_boundaries, ) import numpy as np @@ -79,7 +78,7 @@ def test_get_segment_durations(): # Check results expected_start_stop_times = np.array([(10, 15), (20, 30), (30, 45)], dtype=float) assert list(segment_start_stop_times) == segment_indices - start_stop_times = segment_start_stop_times_to_boundaries(segment_start_stop_times).reshape(-1, 2) + start_stop_times = np.array(list(segment_start_stop_times.values())) assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Check results @@ -103,7 +102,7 @@ def test_get_segment_durations(): expected_start_stop_times = [(4, 11)] assert list(segment_start_stop_times) == [0] - start_stop_times = segment_start_stop_times_to_boundaries(segment_start_stop_times).reshape(-1, 2) + start_stop_times = np.array(list(segment_start_stop_times.values())) assert np.allclose(expected_start_stop_times, start_stop_times, rtol=0, atol=0.1) # Test that the calculated duration is reasonable From 6db46debccca74c604732923bf6890c4abb51a16 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:56:36 +0000 Subject: [PATCH 23/37] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/widgets/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 2183bdfa03..3602f9f07c 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -429,6 +429,7 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts ) + def compute_segment_durations_from_spike_vector( spike_vector: np.ndarray, segment_indices: list[int], From a416cfec6059c1603daaa9888680574b70353d9c Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 09:32:50 +0100 Subject: [PATCH 24/37] Dynamically use tick labels. --- src/spikeinterface/widgets/rasters.py | 35 ++++++++++++++++++++++----- src/spikeinterface/widgets/utils.py | 10 ++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index d8af38a856..eaf920b0f9 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -116,9 +116,22 @@ def __init__( all_units.update(spike_train_data[seg_idx].keys()) unit_ids = list(all_units) + time_offsets = None # Drop segment times we don't want to use if segment_start_stop_times is not None: segment_start_stop_times = {seg: segment_start_stop_times[seg] for seg in segments_to_use} + if len(segment_start_stop_times) > 1: + starts, stops = np.array(list(segment_start_stop_times.values())).T + display_starts = starts[0] + np.r_[0, np.cumsum(stops - starts)[:-1]] + time_offsets = starts - display_starts + spike_train_data = { + seg: {unit: times - offset for unit, times in spike_train_data[seg].items()} + for seg, offset in zip(segments_to_use, time_offsets) + } + segment_start_stop_times = { + seg: (start, start + stop - original) + for seg, start, original, stop in zip(segments_to_use, display_starts, starts, stops) + } # Concatenate data across segments with proper time offsets concatenated_spike_trains = {unit_id: np.array([]) for unit_id in unit_ids} @@ -155,6 +168,7 @@ def __init__( y_label=y_label, title=title, segment_start_stop_times=segment_start_stop_times, + time_offsets=time_offsets, plot_legend=plot_legend, bins=bins, y_ticks=y_ticks, @@ -167,6 +181,7 @@ def __init__( def plot_matplotlib(self, data_plot, **backend_kwargs): import matplotlib.pyplot as plt from matplotlib.colors import Normalize + from matplotlib.ticker import ScalarFormatter from .utils_matplotlib import make_mpl_figure dp = to_attr(data_plot) @@ -248,16 +263,24 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): # Add segment boundary lines if provided and handle x limits if dp.segment_start_stop_times is not None: - # We only plot boundaries for sequential segments. - segment_boundaries = np.array(list(dp.segment_start_stop_times.values())).ravel() + segment_times = np.array(list(dp.segment_start_stop_times.values())) + if len(segment_times) > 1: + time_formatter = ScalarFormatter() + time_formatter.set_axis(scatter_ax.xaxis) - if np.all(np.diff(segment_boundaries) > 0): - for boundary in segment_boundaries: + def format_original_time(x, pos): + segment = np.searchsorted(segment_times[1:, 0], x, side="right") + return time_formatter.format_data_short(x + dp.time_offsets[segment]) + + scatter_ax.locator_params(axis="x", nbins=12) + scatter_ax.xaxis.set_major_formatter(format_original_time) + for boundary in segment_times[1:, 0]: scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) if x_lim is None: - x_lim = (segment_boundaries.min(), segment_boundaries.max()) - scatter_ax.set_xlim(x_lim) + x_lim = (segment_times[0, 0], segment_times[-1, 1]) + if x_lim is not None: + scatter_ax.set_xlim(x_lim) if dp.sort_by_depth and dp.depth_dict is not None: scatter_ax.set_yticks(ticks=list(range(len(dp.depth_dict))), labels=list(dp.depth_dict.keys())) diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 3602f9f07c..9d6b7d8848 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -421,6 +421,16 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non if segment_indices is None: segment_indices = range(sorting.get_num_segments()) + if sorting.has_recording(): + # Include the final sample interval; end_time is the last sample's timestamp. + sample_period = 1 / sorting.get_sampling_frequency() + segment_start_stop_times = { + seg: (sorting.get_start_time(seg), sorting.get_end_time(seg) + sample_period) + for seg in segment_indices + } + durations = np.array([stop - start for start, stop in segment_start_stop_times.values()]) + return durations, segment_start_stop_times + spikes = sorting.to_spike_vector() segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] From 7939950b6a4f81bbbb303f50953530f3c3b677c0 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 10:44:05 +0100 Subject: [PATCH 25/37] Remove using get segment times on sorting for now. --- src/spikeinterface/widgets/utils.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 9d6b7d8848..3602f9f07c 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -421,16 +421,6 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non if segment_indices is None: segment_indices = range(sorting.get_num_segments()) - if sorting.has_recording(): - # Include the final sample interval; end_time is the last sample's timestamp. - sample_period = 1 / sorting.get_sampling_frequency() - segment_start_stop_times = { - seg: (sorting.get_start_time(seg), sorting.get_end_time(seg) + sample_period) - for seg in segment_indices - } - durations = np.array([stop - start for start, stop in segment_start_stop_times.values()]) - return durations, segment_start_stop_times - spikes = sorting.to_spike_vector() segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] From 7d102e2ed94d882ee1dd7675f3cbb512793fbe8f Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 11:29:20 +0100 Subject: [PATCH 26/37] Use recording times for start/stop times if htey exist in get_segment_durations. --- src/spikeinterface/widgets/amplitudes.py | 1 - src/spikeinterface/widgets/rasters.py | 54 +++++++------------ .../widgets/tests/test_widgets_utils.py | 28 +++++++++- src/spikeinterface/widgets/utils.py | 14 ++++- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/spikeinterface/widgets/amplitudes.py b/src/spikeinterface/widgets/amplitudes.py index 5a129821e8..9627bd90ec 100644 --- a/src/spikeinterface/widgets/amplitudes.py +++ b/src/spikeinterface/widgets/amplitudes.py @@ -121,7 +121,6 @@ def __init__( if plot_histograms and bins is None: bins = 100 - # Calculate durations for all segments for x-axis limits _, segment_start_stop_times = get_segment_durations(sorting, segment_indices) # Build the plot data with the full dict of dicts structure diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index eaf920b0f9..0c0e07a46e 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -116,28 +116,26 @@ def __init__( all_units.update(spike_train_data[seg_idx].keys()) unit_ids = list(all_units) - time_offsets = None + segment_time_offsets = None # Drop segment times we don't want to use if segment_start_stop_times is not None: segment_start_stop_times = {seg: segment_start_stop_times[seg] for seg in segments_to_use} + if len(segment_start_stop_times) > 1: + # When plotting, the segments are aligned such that consecutive segments + # appear directly next to eachother, rather than at their true times. If + # their true times are used, large inter-segment gaps can appear on the plot. + # Compute the segment offsets here, and use them to adjust the spike times + # and for generating new xticklabels later. starts, stops = np.array(list(segment_start_stop_times.values())).T - display_starts = starts[0] + np.r_[0, np.cumsum(stops - starts)[:-1]] - time_offsets = starts - display_starts - spike_train_data = { - seg: {unit: times - offset for unit, times in spike_train_data[seg].items()} - for seg, offset in zip(segments_to_use, time_offsets) - } - segment_start_stop_times = { - seg: (start, start + stop - original) - for seg, start, original, stop in zip(segments_to_use, display_starts, starts, stops) - } + segment_time_offsets = np.r_[0, np.cumsum(starts[1:] - stops[:-1])] # Concatenate data across segments with proper time offsets concatenated_spike_trains = {unit_id: np.array([]) for unit_id in unit_ids} concatenated_y_axis = {unit_id: np.array([]) for unit_id in unit_ids} - for spike_train_segment, y_axis_segment in zip( + for segment_index, spike_train_segment, y_axis_segment in zip( + range(len(segments_to_use)), [spike_train_data[idx] for idx in segments_to_use], [y_axis_data[idx] for idx in segments_to_use], ): @@ -149,6 +147,9 @@ def __init__( # Get y-axis values for this unit y_values = y_axis_segment[unit_id] + if segment_time_offsets is not None: + spike_times = spike_times - segment_time_offsets[segment_index] + # Add to concatenated data concatenated_spike_trains[unit_id] = np.concatenate([concatenated_spike_trains[unit_id], spike_times]) concatenated_y_axis[unit_id] = np.concatenate([concatenated_y_axis[unit_id], y_values]) @@ -168,7 +169,7 @@ def __init__( y_label=y_label, title=title, segment_start_stop_times=segment_start_stop_times, - time_offsets=time_offsets, + segment_time_offsets=segment_time_offsets, plot_legend=plot_legend, bins=bins, y_ticks=y_ticks, @@ -265,15 +266,19 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): segment_times = np.array(list(dp.segment_start_stop_times.values())) if len(segment_times) > 1: + + # Update xticklabels for multi-segment times. + segment_times = segment_times - dp.segment_time_offsets[:, None] time_formatter = ScalarFormatter() time_formatter.set_axis(scatter_ax.xaxis) def format_original_time(x, pos): segment = np.searchsorted(segment_times[1:, 0], x, side="right") - return time_formatter.format_data_short(x + dp.time_offsets[segment]) + return time_formatter.format_data_short(x + dp.segment_time_offsets[segment]) scatter_ax.locator_params(axis="x", nbins=12) scatter_ax.xaxis.set_major_formatter(format_original_time) + for boundary in segment_times[1:, 0]: scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) @@ -472,16 +477,9 @@ def __init__( spike_train_data = {seg_idx: {} for seg_idx in segment_indices} y_axis_data = {seg_idx: {} for seg_idx in segment_indices} - segment_start_stop_times = {} + _, segment_start_stop_times = get_segment_durations(sorting, segment_indices) - # Get the spikes for this segment. Also, if the sorting does not have a recording, - # build the segment start / stop times from the spike times. This is not done - # with get_segment_durations to avoid the conversion to spike_vector. for seg_idx in segment_indices: - - min_spiketime = np.inf - max_spiketime = -np.inf - for unit_id in unit_ids: # Get spikes for this segment and unit spike_times = sorting.get_unit_spike_train_in_seconds(unit_id=unit_id, segment_index=seg_idx) @@ -489,18 +487,6 @@ def __init__( spike_train_data[seg_idx][unit_id] = spike_times y_axis_data[seg_idx][unit_id] = unit_indices_map[unit_id] * np.ones(len(spike_times)) - if not sorting.has_recording() and len(spike_times) > 0: - min_spiketime = min(min_spiketime, spike_times[0]) - max_spiketime = max(max_spiketime, spike_times[-1]) - - if sorting.has_recording(): - segment_start_stop_times[seg_idx] = ( - sorting.get_start_time(seg_idx), - sorting.get_end_time(seg_idx), - ) - else: - segment_start_stop_times[seg_idx] = (min_spiketime, max_spiketime) - # Apply time range filtering if specified if time_range is not None: assert len(time_range) == 2, "'time_range' should be a list with start and end time in seconds" diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index 1a3868acea..a9bb9062a5 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -1,6 +1,6 @@ import pytest -from spikeinterface import generate_sorting +from spikeinterface import NumpyRecording, generate_sorting from spikeinterface.widgets.utils import ( get_some_colors, validate_segment_indices, @@ -56,7 +56,8 @@ def test_validate_segment_indices(): validate_segment_indices([5], sorting_multiple) -def test_get_segment_durations(): +def test_get_segment_durations_estimate(): + """Test a sortings get_segment_durations when estimated from the spike time""" from spikeinterface import generate_sorting # Test with a normal multi-segment sorting @@ -110,7 +111,30 @@ def test_get_segment_durations(): assert 7.0 - single_duration < 0.7 # Within 10% + +def test_get_segment_durations_with_recording(): + """Test a sortings get_segment_durations when a recording is + registered, meaning times should be exact and not estimated from spike times""" + durations = [5.0, 10.0, 15.0] + t_starts = [10, 20, 30] + sampling_frequency = 1024 + + sorting = generate_sorting(durations=durations, sampling_frequency=sampling_frequency, seed=42) + + recording = NumpyRecording( + [np.zeros((int(duration * sampling_frequency), 1), dtype="float32") for duration in durations], + sampling_frequency=sampling_frequency, + t_starts=t_starts, + ) + sorting.register_recording(recording) + + actual_durations, bounds = get_segment_durations(sorting) + np.testing.assert_array_equal(actual_durations, durations) + assert bounds == {0: (10, 15), 1: (20, 30), 2: (30, 45)} + + if __name__ == "__main__": test_get_some_colors() test_validate_segment_indices() test_get_segment_durations() + test_get_segment_durations_with_recording() diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 3602f9f07c..6044461712 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -402,7 +402,9 @@ def validate_segment_indices(segment_indices: list[int] | None, sorting: BaseSor def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = None): """ - Calculate the duration of each segment in a sorting object. + Calculate segment durations and exclusive start/stop bounds. + + Use recording-backed bounds when available; otherwise estimate from spikes. Parameters ---------- @@ -421,6 +423,16 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non if segment_indices is None: segment_indices = range(sorting.get_num_segments()) + if sorting.has_recording(): + # Include the final sample interval to return exclusive stop times. + sample_period = 1 / sorting.get_sampling_frequency() + segment_start_stop_times = { + seg: (sorting.get_start_time(seg), sorting.get_end_time(seg) + sample_period) + for seg in segment_indices + } + durations = np.array([stop - start for start, stop in segment_start_stop_times.values()]) + return durations, segment_start_stop_times + spikes = sorting.to_spike_vector() segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] From 14acc7994bc1e81c85b23d780c8b0ecfdc9023fc Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 11:30:51 +0100 Subject: [PATCH 27/37] Linting. --- src/spikeinterface/widgets/tests/test_widgets_utils.py | 1 - src/spikeinterface/widgets/utils.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index a9bb9062a5..e8e0976186 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -111,7 +111,6 @@ def test_get_segment_durations_estimate(): assert 7.0 - single_duration < 0.7 # Within 10% - def test_get_segment_durations_with_recording(): """Test a sortings get_segment_durations when a recording is registered, meaning times should be exact and not estimated from spike times""" diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 6044461712..88f50632a4 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -427,8 +427,7 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non # Include the final sample interval to return exclusive stop times. sample_period = 1 / sorting.get_sampling_frequency() segment_start_stop_times = { - seg: (sorting.get_start_time(seg), sorting.get_end_time(seg) + sample_period) - for seg in segment_indices + seg: (sorting.get_start_time(seg), sorting.get_end_time(seg) + sample_period) for seg in segment_indices } durations = np.array([stop - start for start, stop in segment_start_stop_times.values()]) return durations, segment_start_stop_times From 5e4bf2f07ee1c6e013f3885c576d0fecfa12e2a8 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 11:38:12 +0100 Subject: [PATCH 28/37] Remove playing. --- playing.py | 92 ------------------------------------------------------ 1 file changed, 92 deletions(-) delete mode 100644 playing.py diff --git a/playing.py b/playing.py deleted file mode 100644 index 2894b60265..0000000000 --- a/playing.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Generate a multi-segment recording whose segments start at non-zero times, -then plot a raster of the segments. -""" - -import matplotlib - -matplotlib.use("QtAgg") -import matplotlib.pyplot as plt - -from spikeinterface.core import append_recordings, append_sortings, create_sorting_analyzer, generate_ground_truth_recording -from spikeinterface.generation import generate_drifting_recording -from spikeinterface.preprocessing import compute_motion -import spikeinterface.widgets as sw - - -def make_multi_segment_recording(num_segments=3, seed=2205): - """Build a multi-segment recording and sorting with non-zero segment start times.""" - - common_kwargs = dict( - durations=[10.0], - sampling_frequency=30000.0, - num_channels=64, - num_units=60, - ) - - recordings = [] - sortings = [] - # Offsets so segments do NOT start at time zero. - start_time_offsets = [5.0, 120.0, 260.0] - - for seg in range(num_segments): - recording, sorting = generate_ground_truth_recording(seed=seed + seg, **common_kwargs) - # Give this segment a non-zero start time. - recording.shift_times(shift=start_time_offsets[seg % len(start_time_offsets)]) - recordings.append(recording) - sortings.append(sorting) - - multi_segment_recording = append_recordings(recordings) - multi_segment_sorting = append_sortings(sortings) - return multi_segment_recording, multi_segment_sorting - - -def plot_amplitudes_output(recording, sorting): - """Plot spike amplitudes for a few units across all recording segments.""" - - analyzer = create_sorting_analyzer(sorting=sorting, recording=recording, format="memory") - analyzer.compute(["random_spikes", "waveforms", "templates", "spike_amplitudes"]) - - amplitude_widget = sw.plot_amplitudes( - analyzer, - unit_ids=sorting.unit_ids[:6], - segment_indices=list(range(sorting.get_num_segments())), - plot_histograms=True, - ) - amplitude_widget.figure.canvas.manager.set_window_title("Amplitude output") - amplitude_widget.axes.flatten()[0].set_title("Spike amplitudes") - - -def main(): - recording, sorting = make_multi_segment_recording() - sorting.register_recording(recording) - - for seg in range(recording.get_num_segments()): - print(f" segment {seg}: start={recording.get_start_time(seg):.1f}s end={recording.get_end_time(seg):.1f}s") - - # Plot a raster of all segments. - segment_indices = list(range(sorting.get_num_segments())) - # sw.plot_rasters(sorting, segment_indices=segment_indices) - # plot_amplitudes_output(recording, sorting) - - - motion_recording = recording.select_segments([0]) - motion, motion_info = compute_motion( - motion_recording, - preset="rigid_fast", - estimate_motion_kwargs=dict(method="decentralized", conv_engine="numpy"), - n_jobs=-1, - progress_bar=True, - output_motion_info=True, - ) - print(motion) - motion_widget = sw.plot_motion_info(motion_info, recording=motion_recording) - motion_widget.figure.canvas.manager.set_window_title("Motion output") - motion_widget.figure.canvas.draw() - motion_widget.figure.show() - - plt.show(block=True) - - -if __name__ == "__main__": - main() From c17bbbb32112507634019b897e3046f3d797912b Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 16:24:00 +0100 Subject: [PATCH 29/37] Minor tidy ups and refactorings. --- src/spikeinterface/widgets/motion.py | 7 +-- src/spikeinterface/widgets/rasters.py | 21 ++++++++- .../widgets/tests/test_widgets_utils.py | 5 +- src/spikeinterface/widgets/utils.py | 47 ++++++++++++------- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 36f91a0a0c..8eda12f33e 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -4,7 +4,7 @@ from spikeinterface.core import BaseRecording, SortingAnalyzer from .rasters import BaseRasterWidget -from .utils import compute_segment_durations_from_spike_vector +from .utils import compute_segment_durations_from_spike_vector, get_segment_start_stop_times from spikeinterface.core.motion import Motion @@ -262,10 +262,7 @@ def __init__( # Calculate segment start/stop times for x-axis limits if recording is not None: - segment_start_stop_times = { - seg_idx: (recording.get_start_time(seg_idx), recording.get_end_time(seg_idx)) - for seg_idx in segment_indices - } + segment_start_stop_times = get_segment_start_stop_times(recording, segment_indices) else: # Find boundaries between segments using searchsorted _, segment_start_stop_times = compute_segment_durations_from_spike_vector( diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 0c0e07a46e..b7158452c0 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -182,7 +182,7 @@ def __init__( def plot_matplotlib(self, data_plot, **backend_kwargs): import matplotlib.pyplot as plt from matplotlib.colors import Normalize - from matplotlib.ticker import ScalarFormatter + from matplotlib.ticker import Locator, MaxNLocator, ScalarFormatter from .utils_matplotlib import make_mpl_figure dp = to_attr(data_plot) @@ -276,7 +276,24 @@ def format_original_time(x, pos): segment = np.searchsorted(segment_times[1:, 0], x, side="right") return time_formatter.format_data_short(x + dp.segment_time_offsets[segment]) - scatter_ax.locator_params(axis="x", nbins=12) + class SegmentTimeLocator(Locator): + def __call__(self): + return self.tick_values(*self.axis.get_view_interval()) + + def tick_values(self, vmin, vmax): + vmin, vmax = sorted((vmin, vmax)) + ticks = [] + for (start, stop), offset in zip(segment_times, dp.segment_time_offsets): + left, right = max(start, vmin), min(stop, vmax) + if left >= right: + continue + nbins = max(1, round(self.axis.get_tick_space() * (right - left) / (vmax - vmin))) + local_ticks = MaxNLocator(nbins=nbins).tick_values(left + offset, right + offset) + local_ticks = local_ticks[(local_ticks >= left + offset) & (local_ticks < right + offset)] + ticks.extend(local_ticks - offset) + return self.raise_if_exceeds(np.asarray(ticks)) + + scatter_ax.xaxis.set_major_locator(SegmentTimeLocator()) scatter_ax.xaxis.set_major_formatter(format_original_time) for boundary in segment_times[1:, 0]: diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index e8e0976186..4574218136 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -129,7 +129,10 @@ def test_get_segment_durations_with_recording(): actual_durations, bounds = get_segment_durations(sorting) np.testing.assert_array_equal(actual_durations, durations) - assert bounds == {0: (10, 15), 1: (20, 30), 2: (30, 45)} + assert bounds == { + seg: (start, start + duration - 1 / sampling_frequency) + for seg, (start, duration) in enumerate(zip(t_starts, durations)) + } if __name__ == "__main__": diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 88f50632a4..57c86269b1 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -400,11 +400,24 @@ def validate_segment_indices(segment_indices: list[int] | None, sorting: BaseSor return segment_indices +def get_segment_start_stop_times(recording_or_sorting, segment_indices): + """Return first and last sample timestamps, without extending the stop times. + + The input must be a recording or a sorting with a recording attached. + """ + return { + seg: (recording_or_sorting.get_start_time(seg), recording_or_sorting.get_end_time(seg)) + for seg in segment_indices + } + + def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = None): """ - Calculate segment durations and exclusive start/stop bounds. + Calculate segment durations and start/stop bounds. - Use recording-backed bounds when available; otherwise estimate from spikes. + Use first/last sample timestamps when a recording is available; otherwise + estimate an exclusive stop from spikes. Recording-backed durations include + the final sample interval. Parameters ---------- @@ -424,12 +437,9 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non segment_indices = range(sorting.get_num_segments()) if sorting.has_recording(): - # Include the final sample interval to return exclusive stop times. - sample_period = 1 / sorting.get_sampling_frequency() - segment_start_stop_times = { - seg: (sorting.get_start_time(seg), sorting.get_end_time(seg) + sample_period) for seg in segment_indices - } - durations = np.array([stop - start for start, stop in segment_start_stop_times.values()]) + segment_start_stop_times = get_segment_start_stop_times(sorting, segment_indices) + ts = 1 / sorting.get_sampling_frequency() + durations = np.array([stop - start + ts for start, stop in segment_start_stop_times.values()]) return durations, segment_start_stop_times spikes = sorting.to_spike_vector() @@ -450,18 +460,21 @@ def compute_segment_durations_from_spike_vector( """ If segment_t_starts is `None` then assume 0 """ - if segment_t_starts is None: - segment_t_starts = [0] * len(segment_indices) + segment_start_stop_times = {} + for i, seg_idx in enumerate(segment_indices): + + if segment_t_starts is None: + segment_start = 0.0 + else: + segment_start = segment_t_starts[i] - segment_boundaries = [ - np.searchsorted(spike_vector["segment_index"], [seg_idx, seg_idx + 1]) for seg_idx in segment_indices - ] + seg_end_idx = np.searchsorted(spike_vector["segment_index"], seg_idx + 1) - segment_start_stop_times = {} - for seg_idx, segment_t_start, (start, end) in zip(segment_indices, segment_t_starts, segment_boundaries): + assert ( + seg_end_idx > 0 and spike_vector["segment_index"][seg_end_idx - 1] == seg_idx + ), f"Cannot estimate duration for segment {seg_idx}: it contains no spikes." - segment_start = spike_vector["sample_index"][start] / sampling_frequency + segment_t_start - segment_end = (spike_vector["sample_index"][end - 1] + 1) / sampling_frequency + segment_t_start + segment_end = (spike_vector["sample_index"][seg_end_idx - 1] + 1) / sampling_frequency + segment_start segment_start_stop_times[seg_idx] = (segment_start, segment_end) From 6336bc24cfe9c9af0633988afc0676cb3eb3098c Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 16:58:00 +0100 Subject: [PATCH 30/37] More dynamic ticks. --- src/spikeinterface/widgets/amplitudes.py | 1 + src/spikeinterface/widgets/motion.py | 1 + src/spikeinterface/widgets/rasters.py | 39 +++++++++++++----------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/spikeinterface/widgets/amplitudes.py b/src/spikeinterface/widgets/amplitudes.py index 9627bd90ec..2846be56d4 100644 --- a/src/spikeinterface/widgets/amplitudes.py +++ b/src/spikeinterface/widgets/amplitudes.py @@ -129,6 +129,7 @@ def __init__( plot_histograms=plot_histograms, bins=bins, segment_start_stop_times=segment_start_stop_times, + sampling_frequency=sorting.get_sampling_frequency(), unit_ids=unit_ids, hide_unit_selector=hide_unit_selector, plot_legend=plot_legend, diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 8eda12f33e..24de3e9585 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -279,6 +279,7 @@ def __init__( title="Peak depth", y_label="Depth [um]", segment_start_stop_times=segment_start_stop_times, + sampling_frequency=sampling_frequency, ) BaseRasterWidget.__init__(self, **plot_data, backend=backend, **backend_kwargs) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index b7158452c0..d12ff0f0f8 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -29,8 +29,10 @@ class BaseRasterWidget(BaseWidget): segment_indices : list | None, default: None For multi-segment data, specifies which segment(s) to plot. If None, uses all available segments. For single-segment data, this parameter is ignored. - durations : list | None, default: None + segment_start_stop_times : dict | None, default: None List of durations per segment of spike_train_data in seconds. + sampling_frequency : float | None, default: None + Sampling frequency in Hz, required to space multiple segments. plot_histograms : bool, default: False Plot histogram of y-axis data in another subplot bins : int | None, default: None @@ -86,6 +88,7 @@ def __init__( hide_unit_selector: bool = True, segment_boundary_kwargs: dict | None = None, backend: str | None = None, + sampling_frequency: float | None = None, **backend_kwargs, ): # Set default segment boundary kwargs if not provided @@ -122,13 +125,16 @@ def __init__( segment_start_stop_times = {seg: segment_start_stop_times[seg] for seg in segments_to_use} if len(segment_start_stop_times) > 1: + if sampling_frequency is None: + raise ValueError("sampling_frequency is required when plotting multiple segments") # When plotting, the segments are aligned such that consecutive segments # appear directly next to eachother, rather than at their true times. If # their true times are used, large inter-segment gaps can appear on the plot. - # Compute the segment offsets here, and use them to adjust the spike times - # and for generating new xticklabels later. + # 1 ts padding is used to ensure the end of one segment is not the same as the + # start time of the next segment. starts, stops = np.array(list(segment_start_stop_times.values())).T - segment_time_offsets = np.r_[0, np.cumsum(starts[1:] - stops[:-1])] + ts = 1 / sampling_frequency + segment_time_offsets = np.r_[0, np.cumsum(starts[1:] - (stops[:-1] + ts))] # Concatenate data across segments with proper time offsets concatenated_spike_trains = {unit_id: np.array([]) for unit_id in unit_ids} @@ -182,7 +188,7 @@ def __init__( def plot_matplotlib(self, data_plot, **backend_kwargs): import matplotlib.pyplot as plt from matplotlib.colors import Normalize - from matplotlib.ticker import Locator, MaxNLocator, ScalarFormatter + from matplotlib.ticker import MaxNLocator, ScalarFormatter from .utils_matplotlib import make_mpl_figure dp = to_attr(data_plot) @@ -276,24 +282,22 @@ def format_original_time(x, pos): segment = np.searchsorted(segment_times[1:, 0], x, side="right") return time_formatter.format_data_short(x + dp.segment_time_offsets[segment]) - class SegmentTimeLocator(Locator): - def __call__(self): - return self.tick_values(*self.axis.get_view_interval()) - + class SegmentTimeLocator(MaxNLocator): def tick_values(self, vmin, vmax): vmin, vmax = sorted((vmin, vmax)) + self.set_params(nbins=max(1, self.axis.get_tick_space())) + step = np.diff(super().tick_values(0, vmax - vmin))[0] ticks = [] - for (start, stop), offset in zip(segment_times, dp.segment_time_offsets): + for start, stop in segment_times: left, right = max(start, vmin), min(stop, vmax) - if left >= right: + if left > right: continue - nbins = max(1, round(self.axis.get_tick_space() * (right - left) / (vmax - vmin))) - local_ticks = MaxNLocator(nbins=nbins).tick_values(left + offset, right + offset) - local_ticks = local_ticks[(local_ticks >= left + offset) & (local_ticks < right + offset)] - ticks.extend(local_ticks - offset) - return self.raise_if_exceeds(np.asarray(ticks)) + first = max(0, int(np.ceil((left - start) / step))) + last = int(np.floor((right - start) / step)) + ticks.extend(start + np.arange(first, last + 1) * step) + return self.raise_if_exceeds(np.unique(ticks)) - scatter_ax.xaxis.set_major_locator(SegmentTimeLocator()) + scatter_ax.xaxis.set_major_locator(SegmentTimeLocator(steps=[1, 2, 2.5, 5, 10])) scatter_ax.xaxis.set_major_formatter(format_original_time) for boundary in segment_times[1:, 0]: @@ -530,6 +534,7 @@ def __init__( plot_histograms=None, y_ticks=y_ticks, segment_start_stop_times=segment_start_stop_times, + sampling_frequency=sorting.get_sampling_frequency(), ) BaseRasterWidget.__init__(self, **plot_data, backend=backend, **backend_kwargs) From 266d837f16dee4afad779b6fe04a516f9c027539 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 14 Oct 2026 17:03:08 +0100 Subject: [PATCH 31/37] Think foudn a happy medium with the ticks. --- src/spikeinterface/widgets/rasters.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index d12ff0f0f8..8cf5f733a8 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -292,9 +292,14 @@ def tick_values(self, vmin, vmax): left, right = max(start, vmin), min(stop, vmax) if left > right: continue - first = max(0, int(np.ceil((left - start) / step))) - last = int(np.floor((right - start) / step)) - ticks.extend(start + np.arange(first, last + 1) * step) + # Allow at least two ticks per segment, even on narrow axes. + segment_step = min( + step, + np.diff(MaxNLocator(nbins=2, steps=[1, 2, 2.5, 5, 10]).tick_values(0, stop - start))[0], + ) + first = max(0, int(np.ceil((left - start) / segment_step))) + last = int(np.floor((right - start) / segment_step)) + ticks.extend(start + np.arange(first, last + 1) * segment_step) return self.raise_if_exceeds(np.unique(ticks)) scatter_ax.xaxis.set_major_locator(SegmentTimeLocator(steps=[1, 2, 2.5, 5, 10])) From a34b786967e0556ee462a28767223c49e577d821 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 16 Oct 2026 16:25:39 +0100 Subject: [PATCH 32/37] rename compute to estimate. --- src/spikeinterface/widgets/motion.py | 4 ++-- src/spikeinterface/widgets/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 24de3e9585..58b0da7111 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -4,7 +4,7 @@ from spikeinterface.core import BaseRecording, SortingAnalyzer from .rasters import BaseRasterWidget -from .utils import compute_segment_durations_from_spike_vector, get_segment_start_stop_times +from .utils import estimate_segment_durations_from_spike_vector, get_segment_start_stop_times from spikeinterface.core.motion import Motion @@ -265,7 +265,7 @@ def __init__( segment_start_stop_times = get_segment_start_stop_times(recording, segment_indices) else: # Find boundaries between segments using searchsorted - _, segment_start_stop_times = compute_segment_durations_from_spike_vector( + _, segment_start_stop_times = estimate_segment_durations_from_spike_vector( filtered_peaks, segment_indices, sampling_frequency ) diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 57c86269b1..6bcbb62680 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -446,12 +446,12 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non segment_t_starts = [sorting.get_start_time(seg_idx) for seg_idx in segment_indices] - return compute_segment_durations_from_spike_vector( + return estimate_segment_durations_from_spike_vector( spikes, segment_indices, sorting.get_sampling_frequency(), segment_t_starts ) -def compute_segment_durations_from_spike_vector( +def estimate_segment_durations_from_spike_vector( spike_vector: np.ndarray, segment_indices: list[int], sampling_frequency: float, From e063ecf361c10bfc06805ac63f80d8fc47f3d052 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 16 Oct 2026 16:58:40 +0100 Subject: [PATCH 33/37] Remove some locator formatting as default works pretty well. --- src/spikeinterface/widgets/rasters.py | 59 +++++++++++---------------- src/spikeinterface/widgets/utils.py | 4 +- 2 files changed, 26 insertions(+), 37 deletions(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index 8cf5f733a8..f8ef3f326b 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -127,11 +127,9 @@ def __init__( if len(segment_start_stop_times) > 1: if sampling_frequency is None: raise ValueError("sampling_frequency is required when plotting multiple segments") - # When plotting, the segments are aligned such that consecutive segments - # appear directly next to eachother, rather than at their true times. If - # their true times are used, large inter-segment gaps can appear on the plot. - # 1 ts padding is used to ensure the end of one segment is not the same as the - # start time of the next segment. + # Align the segment times such that consecutive segments appear directly next to each other + # on the plot, rather than at their true times (if true times are used, large inter-segment + # gaps can appear on the plot) starts, stops = np.array(list(segment_start_stop_times.values())).T ts = 1 / sampling_frequency segment_time_offsets = np.r_[0, np.cumsum(starts[1:] - (stops[:-1] + ts))] @@ -188,7 +186,6 @@ def __init__( def plot_matplotlib(self, data_plot, **backend_kwargs): import matplotlib.pyplot as plt from matplotlib.colors import Normalize - from matplotlib.ticker import MaxNLocator, ScalarFormatter from .utils_matplotlib import make_mpl_figure dp = to_attr(data_plot) @@ -273,37 +270,8 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): segment_times = np.array(list(dp.segment_start_stop_times.values())) if len(segment_times) > 1: - # Update xticklabels for multi-segment times. segment_times = segment_times - dp.segment_time_offsets[:, None] - time_formatter = ScalarFormatter() - time_formatter.set_axis(scatter_ax.xaxis) - - def format_original_time(x, pos): - segment = np.searchsorted(segment_times[1:, 0], x, side="right") - return time_formatter.format_data_short(x + dp.segment_time_offsets[segment]) - - class SegmentTimeLocator(MaxNLocator): - def tick_values(self, vmin, vmax): - vmin, vmax = sorted((vmin, vmax)) - self.set_params(nbins=max(1, self.axis.get_tick_space())) - step = np.diff(super().tick_values(0, vmax - vmin))[0] - ticks = [] - for start, stop in segment_times: - left, right = max(start, vmin), min(stop, vmax) - if left > right: - continue - # Allow at least two ticks per segment, even on narrow axes. - segment_step = min( - step, - np.diff(MaxNLocator(nbins=2, steps=[1, 2, 2.5, 5, 10]).tick_values(0, stop - start))[0], - ) - first = max(0, int(np.ceil((left - start) / segment_step))) - last = int(np.floor((right - start) / segment_step)) - ticks.extend(start + np.arange(first, last + 1) * segment_step) - return self.raise_if_exceeds(np.unique(ticks)) - - scatter_ax.xaxis.set_major_locator(SegmentTimeLocator(steps=[1, 2, 2.5, 5, 10])) - scatter_ax.xaxis.set_major_formatter(format_original_time) + self._set_segment_time_ticks(scatter_ax, segment_times, dp.segment_time_offsets) for boundary in segment_times[1:, 0]: scatter_ax.axvline(boundary, **dp.segment_boundary_kwargs) @@ -438,6 +406,25 @@ def _update_plot(self, change=None): self.figure.canvas.flush_events() + @staticmethod + def _set_segment_time_ticks(ax, segment_times, segment_offsets): + """Label concatenated segments with original times, using default tick placement. + + ``segment_times`` contains displayed bounds; ``segment_offsets`` restores + each segment's original timestamps after gaps have been removed. + """ + from matplotlib.ticker import ScalarFormatter + + time_formatter = ScalarFormatter() + time_formatter.set_axis(ax.xaxis) + + def format_original_time(x, pos): + segment = np.searchsorted(segment_times[1:, 0], x, side="right") + return time_formatter.format_data_short(x + segment_offsets[segment]) + + ax.xaxis.set_major_formatter(format_original_time) + + class RasterWidget(BaseRasterWidget): """ Plots spike train rasters. diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 6bcbb62680..f5c40530b5 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -440,6 +440,7 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non segment_start_stop_times = get_segment_start_stop_times(sorting, segment_indices) ts = 1 / sorting.get_sampling_frequency() durations = np.array([stop - start + ts for start, stop in segment_start_stop_times.values()]) + return durations, segment_start_stop_times spikes = sorting.to_spike_vector() @@ -458,7 +459,8 @@ def estimate_segment_durations_from_spike_vector( segment_t_starts: list[float] | None = None, ): """ - If segment_t_starts is `None` then assume 0 + When we don't have the true segment times, we can estimate the + start and stop times for each segment from the spike times. """ segment_start_stop_times = {} for i, seg_idx in enumerate(segment_indices): From 9a9d3e2e5c9d7ee65ea025e6fe2475c3eba68958 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 16 Oct 2026 17:07:14 +0100 Subject: [PATCH 34/37] Small doc tidying up. --- src/spikeinterface/widgets/utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index f5c40530b5..7a87431079 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -401,9 +401,8 @@ def validate_segment_indices(segment_indices: list[int] | None, sorting: BaseSor def get_segment_start_stop_times(recording_or_sorting, segment_indices): - """Return first and last sample timestamps, without extending the stop times. - - The input must be a recording or a sorting with a recording attached. + """Convenience function to return the start and stop times of + a recording or sorting in the `segment_start_stop_times` format. """ return { seg: (recording_or_sorting.get_start_time(seg), recording_or_sorting.get_end_time(seg)) @@ -413,11 +412,10 @@ def get_segment_start_stop_times(recording_or_sorting, segment_indices): def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = None): """ - Calculate segment durations and start/stop bounds. + Calculate the duration and start, stop times of each segment in a sorting object. Use first/last sample timestamps when a recording is available; otherwise - estimate an exclusive stop from spikes. Recording-backed durations include - the final sample interval. + estimate from spikes. Parameters ---------- @@ -431,7 +429,7 @@ def get_segment_durations(sorting: BaseSorting, segment_indices: list[int] = Non durations : np.ndarray Segment durations in seconds, ordered like ``segment_indices``. segment_start_stop_times : dict[int, tuple[float, float]] - Start and stop times keyed by segment index. + Start and stop times of each segment, where the dict key is segment index. """ if segment_indices is None: segment_indices = range(sorting.get_num_segments()) From 3851e2d70eb691bcc1c7a007e6a4189f9ab6d20a Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 16 Oct 2026 17:11:34 +0100 Subject: [PATCH 35/37] ensure all methods return inclusive stop times. --- src/spikeinterface/widgets/utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index 7a87431079..e0f1a023d5 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -459,6 +459,8 @@ def estimate_segment_durations_from_spike_vector( """ When we don't have the true segment times, we can estimate the start and stop times for each segment from the spike times. + + Starts are supplied or default to zero. Durations include the final sample interval. """ segment_start_stop_times = {} for i, seg_idx in enumerate(segment_indices): @@ -474,12 +476,11 @@ def estimate_segment_durations_from_spike_vector( seg_end_idx > 0 and spike_vector["segment_index"][seg_end_idx - 1] == seg_idx ), f"Cannot estimate duration for segment {seg_idx}: it contains no spikes." - segment_end = (spike_vector["sample_index"][seg_end_idx - 1] + 1) / sampling_frequency + segment_start + segment_end = spike_vector["sample_index"][seg_end_idx - 1] / sampling_frequency + segment_start segment_start_stop_times[seg_idx] = (segment_start, segment_end) - durations = np.array( - [segment_start_stop_times[seg_idx][1] - segment_start_stop_times[seg_idx][0] for seg_idx in segment_indices] - ) + ts = 1 / sampling_frequency + durations = np.array([stop - start + ts for start, stop in segment_start_stop_times.values()]) return durations, segment_start_stop_times From eddf60880cbb3252dee90541383ad0b71f93226e Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 16 Oct 2026 17:34:15 +0100 Subject: [PATCH 36/37] Linting. --- src/spikeinterface/widgets/rasters.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spikeinterface/widgets/rasters.py b/src/spikeinterface/widgets/rasters.py index f8ef3f326b..2cc054f152 100644 --- a/src/spikeinterface/widgets/rasters.py +++ b/src/spikeinterface/widgets/rasters.py @@ -405,7 +405,6 @@ def _update_plot(self, change=None): self.figure.canvas.draw() self.figure.canvas.flush_events() - @staticmethod def _set_segment_time_ticks(ax, segment_times, segment_offsets): """Label concatenated segments with original times, using default tick placement. From 02a7b1d8028d2e5c7603161c80b6bd8e2517ba3f Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 16 Oct 2026 20:01:43 +0100 Subject: [PATCH 37/37] Better handle empty segments. --- .../widgets/tests/test_widgets_utils.py | 2 +- src/spikeinterface/widgets/utils.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/widgets/tests/test_widgets_utils.py b/src/spikeinterface/widgets/tests/test_widgets_utils.py index 4574218136..92b5fd2e64 100644 --- a/src/spikeinterface/widgets/tests/test_widgets_utils.py +++ b/src/spikeinterface/widgets/tests/test_widgets_utils.py @@ -138,5 +138,5 @@ def test_get_segment_durations_with_recording(): if __name__ == "__main__": test_get_some_colors() test_validate_segment_indices() - test_get_segment_durations() + test_get_segment_durations_estimate() test_get_segment_durations_with_recording() diff --git a/src/spikeinterface/widgets/utils.py b/src/spikeinterface/widgets/utils.py index e0f1a023d5..9d7be46cd4 100644 --- a/src/spikeinterface/widgets/utils.py +++ b/src/spikeinterface/widgets/utils.py @@ -461,8 +461,11 @@ def estimate_segment_durations_from_spike_vector( start and stop times for each segment from the spike times. Starts are supplied or default to zero. Durations include the final sample interval. + Empty segments have zero duration and placeholder bounds from their start to one sample interval later. """ segment_start_stop_times = {} + durations = [] + ts = 1 / sampling_frequency for i, seg_idx in enumerate(segment_indices): if segment_t_starts is None: @@ -472,15 +475,16 @@ def estimate_segment_durations_from_spike_vector( seg_end_idx = np.searchsorted(spike_vector["segment_index"], seg_idx + 1) - assert ( - seg_end_idx > 0 and spike_vector["segment_index"][seg_end_idx - 1] == seg_idx - ), f"Cannot estimate duration for segment {seg_idx}: it contains no spikes." + if seg_end_idx == 0 or spike_vector["segment_index"][seg_end_idx - 1] != seg_idx: + segment_start_stop_times[seg_idx] = (segment_start, segment_start + ts) + durations.append(0.0) + continue segment_end = spike_vector["sample_index"][seg_end_idx - 1] / sampling_frequency + segment_start segment_start_stop_times[seg_idx] = (segment_start, segment_end) + durations.append(segment_end - segment_start + ts) - ts = 1 / sampling_frequency - durations = np.array([stop - start + ts for start, stop in segment_start_stop_times.values()]) + durations = np.array(durations) return durations, segment_start_stop_times