Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/modules/sortingcomponents.rst
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ Here is an example with non-rigid motion estimation:
method="dredge_ap",
rigid=False,
win_shape="gaussian",
win_step_um=200.0,
win_step_um=400.0,
win_scale_um=300.0,
win_margin_um=None,
bin_um=1.0,
Expand Down
39 changes: 39 additions & 0 deletions doc/releases/0.105.4.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
.. _release0.105.4:

SpikeInterface 0.105.4 release notes
------------------------------------

October 27th 2026

Minor release with bug fixes

core:

* Allow for ``main_channel_indices`` to use ``rec_attributes`` (#4867)
* Remove legacy ``location`` property when loading old recordings (#4896)
* Fix: do not include ``extra_metadata`` in zarr provenance dump (#4885)

preprocessing:

* Feat: expose ``periods`` property in silence_periods (#4897)
* Dredge fixes (#4889)
* Save ``dtype`` in ``phase_shift`` kwargs (#4886)
* Make motion-corrected and silenced recordings JSON-serializable (#4877)

extractors:

* Fix: openephys - check ``sync_times`` only if not None (#4890)

sorters:

* Add ``main_channel_id`` to Lupin (#4894)


Contributors:

* @alejoe91
Comment thread
alejoe91 marked this conversation as resolved.
Resolved
* @chrishalcrow
* @cwindolf
Comment thread
alejoe91 marked this conversation as resolved.
Resolved
* @galenlynch
* @h-mayorquin
* @yger
Comment thread
alejoe91 marked this conversation as resolved.
Resolved
3 changes: 2 additions & 1 deletion doc/whatisnew.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Release notes
.. toctree::
:maxdepth: 1

releases/0.105.4.rst
releases/0.105.3.rst
releases/0.105.2.rst
releases/0.105.1.rst
Expand Down Expand Up @@ -66,7 +67,7 @@ Release notes
releases/0.9.1.rst


Versions 0.105.1/3
Versions 0.105.1/4
==================

* Minor releases with bug fixes and performance improvements
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "spikeinterface"
version = "0.105.3"
version = "0.105.4"
authors = [
{ name="Alessio Buccino", email="[email protected]" },
{ name="Samuel Garcia", email="[email protected]" },
Expand Down
1 change: 1 addition & 0 deletions src/spikeinterface/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ def to_dict(
to_dict_kwargs = dict(
include_annotations=include_annotations,
include_properties=include_properties,
include_extra_metadata=include_extra_metadata,
# make_paths_relative() will make the recursivity later:
relative_to=None,
recursive=recursive,
Expand Down
6 changes: 3 additions & 3 deletions src/spikeinterface/core/baserecordingsnippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,14 +321,13 @@ def _extra_metadata_copy(self, other):
other._probegroup = self._probegroup.copy()

def _extra_metadata_from_dict(self, dump_dict):
# load probe and handle backward-compatibility with legacy "contact_vector"/"location" property
# load probegroup
if "probegroup" in dump_dict:
# this is for SI>=0.105.0
probegroup = dump_dict["probegroup"]
self._probegroup = ProbeGroup.from_dict(probegroup)

def _extra_metadata_to_dict(self, dump_dict):
# save probe
# save probegroup
if self.has_probe():
probegroup = self.get_probegroup()
dump_dict["probegroup"] = probegroup.to_dict()
Expand Down Expand Up @@ -360,6 +359,7 @@ def _handle_extractor_backward_compatibility(self):
locations = self.get_property("location")
self.set_dummy_probe_from_locations(locations)
check_for_probes_info = True
self.delete_property("location")

if check_for_probes_info:
for i, probe in enumerate(self._probegroup.probes):
Expand Down
7 changes: 4 additions & 3 deletions src/spikeinterface/core/motion.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,11 @@ def to_dict(self):

@staticmethod
def from_dict(d):
# arrays are lists when the dict comes from json
return Motion(
d["displacement"],
d["temporal_bins_s"],
d["spatial_bins_um"],
[np.asarray(displacement) for displacement in d["displacement"]],
[np.asarray(temporal_bins_s) for temporal_bins_s in d["temporal_bins_s"]],
np.asarray(d["spatial_bins_um"]),
direction=d["direction"],
interpolation_method=d["interpolation_method"],
)
Expand Down
12 changes: 10 additions & 2 deletions src/spikeinterface/core/sortinganalyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,8 +817,16 @@ def _handle_backward_compatibility_settings_pre_init(cls, settings: dict[str, An
def main_channel_indices(self):
if self._main_channel_indices is None:
sorting_main_channel_ids = self.get_sorting_property("main_channel_id")
if sorting_main_channel_ids is not None and self.has_recording():
main_channel_indices = self.recording.ids_to_indices(sorting_main_channel_ids)
if sorting_main_channel_ids is not None and (self.has_recording() or self.rec_attributes):
if self.has_recording():
main_channel_indices = self.recording.ids_to_indices(sorting_main_channel_ids)
elif self.rec_attributes.get("channel_ids") is not None:
channel_ids = np.array(self.rec_attributes.get("channel_ids"))
main_channel_indices = np.array(
[list(channel_ids).index(id) for id in sorting_main_channel_ids], dtype=int
)
else:
main_channel_indices = None
self._main_channel_indices = main_channel_indices
else:
self._main_channel_indices = self._compute_main_channel_backwards_compatibility()
Expand Down
28 changes: 13 additions & 15 deletions src/spikeinterface/core/tests/test_sortinganalyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1160,10 +1160,10 @@ def test_select_channels_independent(dataset):
assert np.all(sa_one.get_channel_locations() == sa_two.get_channel_locations()[shuffle_order])


def test_main_channel_from_templates_dense_recordingless(tmp_path):
"""When a dense analyzer has a `templates` extension but no attached recording, `get_main_channels`
recovers each unit's main channel from the templates (its peak channel) and reports it consistently
both as a positional index and as a channel id.
def test_main_channel_from_templates_dense_no_sorting_main_channel_ids(tmp_path):
"""When a dense analyzer has a `templates` extension but the sorting has no main channel
ids, `get_main_channels, recovers each unit's main channel from the templates (its peak
channel) and reports it consistently both as a positional index and as a channel id.
"""
recording, sorting = generate_ground_truth_recording(
num_channels=8,
Expand All @@ -1189,18 +1189,16 @@ def test_main_channel_from_templates_dense_recordingless(tmp_path):
expected_main_channel_ids = [unit0_main_channel_id, unit1_main_channel_id]

folder = tmp_path / "analyzer_recordingless"
analyzer = create_sorting_analyzer(sorting, recording, format="binary_folder", folder=folder, sparse=False)
analyzer = create_sorting_analyzer(sorting, recording, format="memory", folder=folder, sparse=False)
analyzer.compute(["random_spikes", "templates"])

# simulate the recording being unavailable at load time by removing its serialized copy on disk
for file_ext in ("json", "pickle"):
recording_file = folder / f"recording.{file_ext}"
if recording_file.exists():
recording_file.unlink()
analyzer._recording = None
analyzer.sorting._properties.pop("main_channel_id")

analyzer.save_as(format="binary_folder", folder=folder)

reloaded_analyzer = load_sorting_analyzer(folder)
assert not reloaded_analyzer.has_recording()
assert reloaded_analyzer.has_extension("templates")
assert "main_channel_id" not in reloaded_analyzer.get_sorting_property_keys()

# craft a clear negative peak on each unit's main channel
templates = reloaded_analyzer.get_extension("templates")
Expand All @@ -1217,8 +1215,8 @@ def test_main_channel_from_templates_dense_recordingless(tmp_path):
assert np.array_equal(recovered_main_channel_ids, expected_main_channel_ids)


def test_main_channel_from_templates_sparse_recordingless():
"""When a sparse analyzer has a `templates` extension but no attached recording, `get_main_channels`
def test_main_channel_from_templates_sparse_no_sorting_main_channel_ids():
"""When a sparse analyzer has a `templates` extension but sorting has no main channel ids, `get_main_channels`
reports each unit's main channel as its template peak channel, not the geometric centroid of the
unit's sparse channel set.
"""
Expand Down Expand Up @@ -1272,8 +1270,8 @@ def test_main_channel_from_templates_sparse_recordingless():
templates.data["average"] = average

# recordingless, and force the backwards-compatibility recompute
analyzer._recording = None
analyzer._main_channel_indices = None
analyzer.sorting._properties.pop("main_channel_id")

# must be the template peak channels, not the geometric centroids ("c", "f" / indices [2, 5])
recovered_main_channel_indices = np.asarray(analyzer.get_main_channels(outputs="index"))
Expand Down
4 changes: 3 additions & 1 deletion src/spikeinterface/core/zarrextractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,9 @@ def add_recording_to_zarr_group(
zarr_kwargs, job_kwargs = split_job_kwargs(kwargs)

if recording.check_serializability("json"):
zarr_group.attrs["provenance"] = check_json(recording.to_dict(recursive=True, relative_to=relative_to))
zarr_group.attrs["provenance"] = check_json(
recording.to_dict(recursive=True, include_extra_metadata=False, relative_to=relative_to)
)
else:
zarr_group.attrs["provenance"] = None

Expand Down
2 changes: 1 addition & 1 deletion src/spikeinterface/extractors/neoextractors/openephys.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ def __init__(
sync_times = np.load(stream_folder / "synchronized_timestamps.npy", mmap_mode="r")
else:
sync_times = None
if len(sync_times) == self.segments[segment_index].get_num_samples():
if sync_times is not None and len(sync_times) == self.segments[segment_index].get_num_samples():
self.segments[segment_index]._time_vector = sync_times
else:
warnings.warn(f"Could not load synchronized timestamps for {stream_name}")
Expand Down
2 changes: 1 addition & 1 deletion src/spikeinterface/preprocessing/motion.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
win_step_um=400.0,
win_scale_um=400.0,
win_margin_um=None,
batching_mode="offline",
batching_mode="full",
),
"interpolate_motion_kwargs": dict(
border_mode="force_extrapolate", spatial_interpolation_method="kriging", sigma_um=20.0, p=2
Expand Down
7 changes: 6 additions & 1 deletion src/spikeinterface/preprocessing/phase_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ def __init__(self, recording, margin_ms=40.0, inter_sample_shift=None, dtype=Non
# for dumpability
if inter_sample_shift is not None:
inter_sample_shift = list(inter_sample_shift)
self._kwargs = dict(recording=recording, margin_ms=float(margin_ms), inter_sample_shift=inter_sample_shift)
self._kwargs = dict(
recording=recording,
margin_ms=float(margin_ms),
inter_sample_shift=inter_sample_shift,
dtype=np.dtype(dtype).str,
)


class PhaseShiftRecordingSegment(BasePreprocessorSegment):
Expand Down
10 changes: 8 additions & 2 deletions src/spikeinterface/preprocessing/silence_periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ def __init__(
"list_periods is deprecated. Please specify periods, which should be a np.array with "
f"dtype {base_period_dtype}"
)
if isinstance(periods, list):
# periods reloaded from json are a list of [segment_index, start_sample_index, end_sample_index]
periods = np.array([tuple(period) for period in periods], dtype=base_period_dtype)
if not isinstance(periods, np.ndarray):
raise ValueError(f"periods must be a np.array with dtype {base_period_dtype}")

Expand Down Expand Up @@ -132,8 +135,7 @@ def __init__(
)
self.add_recording_segment(rec_segment)

# the base_period_dtype is a structured dtype, which is not json serializable
self._serializability["json"] = False
self._periods = periods

self._kwargs = dict(
recording=recording,
Expand All @@ -144,6 +146,10 @@ def __init__(
apodization_samples=apodization_samples,
)

@property
def periods(self):
return self._periods


def _all_period_list_to_periods_vec(list_periods, num_seg):
if num_seg == 1:
Expand Down
14 changes: 13 additions & 1 deletion src/spikeinterface/preprocessing/tests/test_phase_shift.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import numpy as np
from spikeinterface import NumpyRecording
from spikeinterface import NumpyRecording, load
from spikeinterface.core.testing import check_recordings_equal

from spikeinterface.preprocessing import phase_shift

Expand Down Expand Up @@ -112,5 +113,16 @@ def signal(time):
np.testing.assert_allclose(shifted_traces[interior], expected[interior], atol=1e-3)


def test_phase_shift_dtype_survives_reload():
traces, sampling_frequency, inter_sample_shift = create_shifted_channel()
recording = NumpyRecording([traces.astype("int16")], sampling_frequency)
recording.set_property("inter_sample_shift", inter_sample_shift)

shifted = phase_shift(recording, dtype="float32")
reloaded = load(shifted.to_dict())

check_recordings_equal(shifted, reloaded, return_in_uV=False)


if __name__ == "__main__":
test_phase_shift()
8 changes: 7 additions & 1 deletion src/spikeinterface/sorters/internal/lupin.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,9 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose):
final_spikes["segment_index"] = spikes["segment_index"]
sorting = NumpySorting(final_spikes, sampling_frequency, templates.unit_ids)

main_channel_ids = templates.get_main_channels(peak_sign=params["peak_sign"], outputs="id")
sorting.set_property("main_channel_id", main_channel_ids)

auto_merge = True
analyzer_final = None
if auto_merge:
Expand All @@ -425,7 +428,10 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose):
debug_folder=None,
job_kwargs=job_kwargs,
)
sorting = NumpySorting.from_sorting(analyzer_final.sorting)
# This is an internal merge, we don't need to expose the "is_merged" property to the user
if "is_merged" in analyzer_final.sorting.get_property_keys():
analyzer_final.sorting.delete_property("is_merged")
sorting = NumpySorting.from_sorting(analyzer_final.sorting, with_metadata=True)

if params["save_array"]:
sorting_pre_peeler = sorting_pre_peeler.save(folder=sorter_output_folder / "sorting_pre_peeler")
Expand Down
Loading